mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Second review follow-up: with several activities (e.g. a splash activity ahead of the game), the alias fallback now prefers the real activity named by the alias's android:targetActivity. Reads targetActivity by resource id, updates the error text and docs/vr-apks.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
298 lines
16 KiB
Python
298 lines
16 KiB
Python
"""Local-only VR manifest, raw ZIP and independent signature checks."""
|
|
import io
|
|
import os
|
|
from pathlib import Path
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
import zipfile
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
|
import frame_apk
|
|
import frame_apk_vr as vr
|
|
import frame_apk_sign as signing
|
|
import frame_android
|
|
from test_frame_apk import pool
|
|
|
|
|
|
DEFAULT = 'android.intent.category.DEFAULT'
|
|
|
|
|
|
def manifest(utf8=False, launcher=False, category=None, samsung=False, split=False, alias=False, splash=False):
|
|
strings = ['manifest', 'package', 'org.test.vr', 'application', 'activity', 'intent-filter',
|
|
'action', 'category', 'name', vr.MAIN, category or next(iter(sorted(vr.VR))),
|
|
vr.LAUNCHER, 'http://schemas.android.com/apk/res/android', 'meta-data', 'value',
|
|
'com.samsung.android.vr.application.mode', 'vr_only', 'activity-alias', DEFAULT, next(iter(sorted(vr.VR))), 'targetActivity', '.Splash', '.Game']
|
|
def start(tag, attrs=()):
|
|
body = struct.pack('<IIHHHHHH', 0xffffffff, strings.index(tag), 20, 20, len(attrs), 0, 0, 0)
|
|
for name, value in attrs:
|
|
ns = 0xffffffff if name == 'package' else 12
|
|
body += struct.pack('<IIIHBBI', ns, strings.index(name), strings.index(value), 8, 0, 3, strings.index(value))
|
|
return struct.pack('<HHIII', 0x102, 16, 16 + len(body), 1, 0xffffffff) + body
|
|
def end(tag):
|
|
return struct.pack('<HHIIIII', 0x103, 16, 24, 1, 0xffffffff, 0xffffffff, strings.index(tag))
|
|
def leaf(tag, attrs):
|
|
return start(tag, attrs) + end(tag)
|
|
b = pool(strings, utf8) + start('manifest', [('package', 'org.test.vr')]) + start('application')
|
|
if samsung:
|
|
b += leaf('meta-data', [('name', strings[15]), ('value', 'vr_only')])
|
|
if splash: # a helper activity with its own MAIN filter, ahead of the game's
|
|
b += start('activity', [('name', '.Splash')]) + start('intent-filter') + leaf('action', [('name', vr.MAIN)])
|
|
b += leaf('category', [('name', DEFAULT)]) + end('intent-filter') + end('activity')
|
|
b += start('activity', [('name', '.Game')] if splash else []) + start('intent-filter') + leaf('action', [('name', vr.MAIN)])
|
|
b += leaf('category', [('name', strings[10])])
|
|
if split:
|
|
b += end('intent-filter') + start('intent-filter')
|
|
if launcher:
|
|
b += leaf('category', [('name', vr.LAUNCHER)])
|
|
b += end('intent-filter') + end('activity')
|
|
if alias: # Godot 4: LAUNCHER only on an alias of the activity ('vr' or 'flat')
|
|
b += start('activity-alias', [('targetActivity', '.Game')] if splash else []) + start('intent-filter') + leaf('action', [('name', vr.MAIN)])
|
|
if alias == 'vr':
|
|
b += leaf('category', [('name', next(iter(sorted(vr.VR))))])
|
|
b += leaf('category', [('name', vr.LAUNCHER)])
|
|
b += end('intent-filter') + end('activity-alias')
|
|
b += end('application') + end('manifest')
|
|
return struct.pack('<HHI', 3, 8, len(b) + 8) + b
|
|
|
|
|
|
class VRTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.tmp = tempfile.TemporaryDirectory()
|
|
cls.keypath = Path(cls.tmp.name) / 'key.json'
|
|
cls.key = signing.signing_key(cls.keypath)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
cls.tmp.cleanup()
|
|
|
|
def test_manifest_patch(self):
|
|
for utf8 in (False, True):
|
|
for category in vr.VR:
|
|
original = manifest(utf8, category=category)
|
|
result = vr.add_launcher_category(original)
|
|
info, filters = vr.inspect(result)
|
|
self.assertTrue(info['launchable'])
|
|
self.assertTrue(info['vr'])
|
|
self.assertIn(vr.LAUNCHER, filters[0]['categories'])
|
|
self.assertEqual(struct.unpack_from('<I', result, 4)[0], len(result))
|
|
self.assertEqual(vr.add_launcher_category(result), result)
|
|
self.assertEqual(vr.add_launcher_category(manifest(utf8, True)), manifest(utf8, True))
|
|
|
|
def test_filter_boundaries(self):
|
|
self.assertFalse(vr.inspect(manifest(launcher=True, split=True))[0]['launchable'])
|
|
self.assertTrue(vr.inspect(vr.add_launcher_category(manifest(launcher=True, split=True)))[0]['launchable'])
|
|
|
|
def test_alias_launcher_is_not_enough(self):
|
|
# (manifest, still VR after patching)
|
|
cases = [(manifest(alias='vr'), True), # Godot 4 VR export
|
|
(manifest(alias='vr', category=DEFAULT), True), # VR category only on the alias
|
|
(manifest(alias='flat', category=DEFAULT), False)] # Godot 4 flat export
|
|
for original, is_vr in cases:
|
|
info, filters = vr.inspect(original)
|
|
self.assertFalse(info['launchable'])
|
|
self.assertTrue(info['repairable'])
|
|
self.assertEqual(len(filters), 1)
|
|
self.assertFalse(filters[0]['alias'])
|
|
result = vr.add_launcher_category(original)
|
|
info, _ = vr.inspect(result)
|
|
self.assertTrue(info['launchable'])
|
|
self.assertFalse(info['repairable'])
|
|
self.assertEqual(info['vr'], is_vr)
|
|
|
|
def test_alias_target_activity_is_patched(self):
|
|
original = manifest(alias='flat', category=DEFAULT, splash=True)
|
|
info, filters = vr.inspect(original)
|
|
self.assertTrue(info['repairable'])
|
|
self.assertEqual(filters[0]['activity'], 'org.test.vr.Game')
|
|
owner, launchers = None, []
|
|
for tag, attrs in frame_apk.manifest_elements(vr.add_launcher_category(original)):
|
|
if tag in ('activity', 'activity-alias'):
|
|
owner = (tag, attrs.get('name', (0, 0, None))[2])
|
|
if tag == 'category' and attrs['name'][2] == vr.LAUNCHER:
|
|
launchers.append(owner)
|
|
self.assertEqual(launchers, [('activity', '.Game'), ('activity-alias', None)])
|
|
|
|
def test_alias_with_launchable_activity_is_left_alone(self):
|
|
original = manifest(launcher=True, alias='vr')
|
|
info, _ = vr.inspect(original)
|
|
self.assertTrue(info['launchable'])
|
|
self.assertFalse(info['repairable'])
|
|
self.assertEqual(vr.add_launcher_category(original), original)
|
|
|
|
def test_patch_alias_apk(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
src, dst = Path(d) / 'in.apk', Path(d) / 'out.apk'
|
|
with zipfile.ZipFile(src, 'w') as z:
|
|
z.writestr('AndroidManifest.xml', manifest(alias='flat', category=DEFAULT))
|
|
with patch.object(signing, 'signing_key', return_value=self.key):
|
|
result = frame_android.patch(src, dst)
|
|
self.assertEqual(result['patched'], ['launcher'])
|
|
self.assertTrue(frame_apk.apk_info(dst)['launchable'])
|
|
self.assertTrue(signing.verify(dst))
|
|
|
|
def test_styled_pool(self):
|
|
for utf8 in (False, True):
|
|
p = bytearray(pool(['styled'], utf8))
|
|
old_start = struct.unpack_from('<I', p, 20)[0]
|
|
p[old_start:old_start] = struct.pack('<I', 0)
|
|
style_start = len(p)
|
|
p += struct.pack('<III', 0, 0, 2) + b'\xff' * 12
|
|
struct.pack_into('<I', p, 4, len(p))
|
|
struct.pack_into('<I', p, 16, (0x100 if utf8 else 0) | 1)
|
|
struct.pack_into('<I', p, 12, 1)
|
|
struct.pack_into('<II', p, 20, old_start + 4, style_start)
|
|
result, idx = vr._append_string(bytes(p), vr.LAUNCHER)
|
|
self.assertEqual(frame_apk._string_pool(result, 0), ['styled', vr.LAUNCHER])
|
|
new_style = struct.unpack_from('<I', result, 24)[0]
|
|
self.assertEqual(result[new_style:], p[style_start:])
|
|
self.assertEqual(len(result) % 4, 0)
|
|
|
|
def test_detection(self):
|
|
names = {'lib/arm64-v8a/' + n for n in ('libvrapi.so', 'libopenxr_loader.so', 'libovrplatformloader.so')}
|
|
info = vr.detection(manifest(category='android.intent.category.LAUNCHER'), names)
|
|
self.assertTrue(info['vr'])
|
|
self.assertTrue(info['launchable'])
|
|
self.assertEqual(len(info['vr_issues']), 3)
|
|
self.assertFalse(vr.detection(manifest(category=vr.LAUNCHER), set())['vr'])
|
|
self.assertTrue(vr.detection(manifest(category=vr.LAUNCHER, samsung=True), set())['vr'])
|
|
|
|
def test_key_cache(self):
|
|
with patch.object(signing, '_prime', side_effect=AssertionError('regenerated')):
|
|
self.assertEqual(signing.signing_key(self.keypath), self.key)
|
|
if os.name != 'nt':
|
|
self.assertEqual(self.keypath.stat().st_mode & 0o777, 0o600)
|
|
|
|
def test_repack_sign_tamper(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
src, dst = Path(d) / 'in.apk', Path(d) / 'out.apk'
|
|
with zipfile.ZipFile(src, 'w') as z:
|
|
z.writestr('AndroidManifest.xml', manifest(), compress_type=8)
|
|
z.writestr('classes.dex', b'compress me' * 10000, compress_type=8)
|
|
z.writestr('resources.arsc', b'1234')
|
|
z.writestr('lib/arm64-v8a/libx.so', b'ELF' * 500000)
|
|
z.writestr('META-INF/OLD.RSA', b'old')
|
|
z.writestr('META-INF/MANIFEST.MF', b'old')
|
|
with patch.object(signing, 'signing_key', return_value=self.key):
|
|
result = frame_android.patch(src, dst, {'assets/layer.json': b'{}', 'lib/arm64-v8a/liblayer.so': b'layer'})
|
|
self.assertEqual(result['patched'], ['launcher'])
|
|
self.assertTrue(frame_apk.apk_info(dst)['launchable'])
|
|
self.assertTrue(signing.verify(dst))
|
|
def compressed(path, info):
|
|
data = path.read_bytes()
|
|
nl, el = struct.unpack_from('<HH', data, info.header_offset + 26)
|
|
start = info.header_offset + 30 + nl + el
|
|
return data[start:start + info.compress_size], start
|
|
with zipfile.ZipFile(src) as a, zipfile.ZipFile(dst) as b:
|
|
self.assertNotIn('META-INF/OLD.RSA', b.namelist())
|
|
self.assertNotIn('META-INF/MANIFEST.MF', b.namelist())
|
|
for i in b.infolist():
|
|
raw, start = compressed(dst, i)
|
|
if i.compress_type == 0:
|
|
self.assertEqual(start % (16384 if i.filename.endswith('.so') else 4), 0)
|
|
if i.filename in ('classes.dex', 'resources.arsc', 'lib/arm64-v8a/libx.so'):
|
|
self.assertEqual(raw, compressed(src, a.getinfo(i.filename))[0])
|
|
_, off = compressed(dst, b.getinfo('resources.arsc'))
|
|
original = dst.read_bytes()
|
|
data = bytearray(original)
|
|
eo, cd = signing._eocd(data)
|
|
size = struct.unpack_from('<Q', data, cd - 24)[0]
|
|
block_start = cd - size - 8
|
|
value = data[block_start + 20:cd - 24]
|
|
signer = signing._parts(signing._parts(value)[0])[0]
|
|
signed, signatures, pub = signing._parts(signer)
|
|
signature = signing._parts(signing._parts(signatures)[0][4:])[0]
|
|
sig_offset = data.index(signature, block_start)
|
|
data[sig_offset] ^= 1
|
|
dst.write_bytes(data)
|
|
with self.assertRaisesRegex(ValueError, 'RSA signature'):
|
|
signing.verify(dst)
|
|
data = bytearray(original)
|
|
data[off] ^= 1
|
|
dst.write_bytes(data)
|
|
with self.assertRaisesRegex(ValueError, 'digest'):
|
|
signing.verify(dst)
|
|
|
|
@unittest.skipUnless(Path('/usr/bin/openssl').exists(), 'openssl absent')
|
|
def test_openssl(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
d = Path(d)
|
|
(d / 'cert.der').write_bytes(signing.certificate(self.key))
|
|
(d / 'message').write_bytes(b'independent signature check')
|
|
(d / 'signature').write_bytes(signing.rsa_sign(b'independent signature check', self.key))
|
|
def run(*args):
|
|
return subprocess.run(['/usr/bin/openssl', *args], check=True, capture_output=True).stdout
|
|
run('x509', '-inform', 'DER', '-in', str(d / 'cert.der'), '-out', str(d / 'cert.pem'))
|
|
run('verify', '-check_ss_sig', '-CAfile', str(d / 'cert.pem'), str(d / 'cert.pem'))
|
|
(d / 'pub.pem').write_bytes(run('x509', '-in', str(d / 'cert.pem'), '-pubkey', '-noout'))
|
|
output = run('dgst', '-sha256', '-verify', str(d / 'pub.pem'), '-signature', str(d / 'signature'), str(d / 'message'))
|
|
self.assertIn(b'Verified OK', output)
|
|
|
|
def test_install_auto_and_override(self):
|
|
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None,
|
|
'vr': True, 'vr_activity': True, 'launchable': False, 'repairable': True}
|
|
with patch.object(frame_android, 'apk_info', return_value=base), \
|
|
patch.object(frame_android, 'xr_compat_files', return_value={}), \
|
|
patch.object(frame_android, 'patch', return_value={'patched': ['launcher']}) as repair, \
|
|
patch.object(frame_android, '_install', return_value={}) as install:
|
|
frame_android.install('original.apk')
|
|
repair.assert_called_once()
|
|
self.assertFalse(install.call_args.args[3])
|
|
self.assertEqual(install.call_args.args[1]['patched'], ['launcher'])
|
|
frame_android.install('original.apk', flatscreen=True)
|
|
self.assertTrue(install.call_args.args[3])
|
|
|
|
def test_xr_compat_layer(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
apk = Path(d) / 'a.apk'
|
|
with zipfile.ZipFile(apk, 'w') as z:
|
|
z.writestr('lib/arm64-v8a/libopenxr_loader.so', b'')
|
|
add = frame_android.xr_compat_files(str(apk))
|
|
self.assertEqual(set(add), set(frame_android.XR_COMPAT_FILES))
|
|
self.assertIn(b'XR_APILAYER_FRAME_compat', add['assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json'])
|
|
self.assertTrue(add['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so'].startswith(b'\x7fELF'))
|
|
with zipfile.ZipFile(apk, 'a') as z: # already injected: nothing more to add
|
|
z.writestr('lib/arm64-v8a/libXrApiLayer_FRAME_compat.so', b'')
|
|
self.assertEqual(frame_android.xr_compat_files(str(apk)), {})
|
|
flat = Path(d) / 'flat.apk'
|
|
with zipfile.ZipFile(flat, 'w') as z:
|
|
z.writestr('classes.dex', b'')
|
|
self.assertEqual(frame_android.xr_compat_files(str(flat)), {})
|
|
|
|
def test_xr_compat_layer_missing(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
apk = Path(d) / 'a.apk'
|
|
with zipfile.ZipFile(apk, 'w') as z:
|
|
z.writestr('lib/arm64-v8a/libopenxr_loader.so', b'')
|
|
with patch.object(frame_android, 'XR_COMPAT', d):
|
|
with self.assertRaisesRegex(frame_android.FrameError, 'build.sh'):
|
|
frame_android.xr_compat_files(str(apk))
|
|
|
|
def test_patch_rejects_corrupt_manifest_cleanly(self):
|
|
with patch.object(frame_android, 'apk_info', side_effect=struct.error('bad')):
|
|
with self.assertRaises(frame_android.FrameError):
|
|
frame_android.patch('x.apk', 'y.apk')
|
|
|
|
def test_install_adds_layer_to_vr_apps(self):
|
|
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None,
|
|
'vr': True, 'vr_activity': True, 'launchable': True, 'repairable': False}
|
|
layer = {'x': b''}
|
|
with patch.object(frame_android, 'apk_info', return_value=dict(base)), \
|
|
patch.object(frame_android, 'xr_compat_files', return_value=layer), \
|
|
patch.object(frame_android, 'patch', return_value={'patched': ['openxr-compat']}) as repair, \
|
|
patch.object(frame_android, '_install', return_value={}) as install:
|
|
frame_android.install('game.apk')
|
|
self.assertIs(repair.call_args.args[2], layer)
|
|
self.assertEqual(install.call_args.args[1]['patched'], ['openxr-compat'])
|
|
repair.reset_mock()
|
|
frame_android.install('game.apk', xr_compat=False) # launchable, no layer: install as is
|
|
repair.assert_not_called()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|