Patch the activity the launcher alias targets, not the first MAIN filter

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>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 20:55:56 +10:00
1 parent 268afccf05
commit 9e9e5950d0
5 files changed
+37 -10

No files matched your search

+5 -2
View File
@@ -100,8 +100,11 @@ VR apps default to immersive mode without the flatscreen marker. The upload
selector or CLI `--flat` / `--vr` overrides that choice. Compatibility notes
identify legacy VrApi, Meta platform SDK and OpenXR libraries.
If a VR MAIN intent filter lacks LAUNCHER, Frame Control inserts it, repacks
and v2-signs the APK locally before copying it; `meta.json` records
Lepton only starts an `<activity>` whose MAIN intent filter has LAUNCHER; it
ignores `<activity-alias>`, which is where Godot 4 exports put LAUNCHER. When no
real activity qualifies, Frame Control adds LAUNCHER to the VR activity's MAIN
filter, or to the activity the launcher alias targets, then repacks and v2-signs
the APK locally before copying it; `meta.json` records
`"patched": ["launcher"]`. Unchanged ZIP members retain their compressed
bytes; stored libraries are aligned to 16 KiB. The RSA signing identity lives
in Frame Control's per-user app-data directory as `apk-signing-key.json`
+20 -4
View File
@@ -21,11 +21,11 @@ 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):
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)))]
'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:
@@ -39,7 +39,10 @@ def manifest(utf8=False, launcher=False, category=None, samsung=False, split=Fal
b = pool(strings, utf8) + start('manifest', [('package', 'org.test.vr')]) + start('application')
if samsung:
b += leaf('meta-data', [('name', strings[15]), ('value', 'vr_only')])
b += start('activity') + start('intent-filter') + leaf('action', [('name', vr.MAIN)])
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')
@@ -47,7 +50,7 @@ def manifest(utf8=False, launcher=False, category=None, samsung=False, split=Fal
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') + start('intent-filter') + leaf('action', [('name', vr.MAIN)])
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)])
@@ -101,6 +104,19 @@ class VRTests(unittest.TestCase):
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)
+1 -1
View File
@@ -322,7 +322,7 @@ def patch(src, dst, add=None):
original = frame_apk._read(z, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST)
manifest = add_launcher_category(original) if info['repairable'] else original
if not info['launchable'] and not info['repairable']:
raise FrameError('APK has no MAIN/LAUNCHER or patchable VR activity')
raise FrameError('APK has no MAIN/LAUNCHER activity that Frame Control can patch')
repack(src, dst, replace={'AndroidManifest.xml': manifest}, add=add)
result = apk_info(dst)
result.pop('icon_png', None)
+2 -1
View File
@@ -10,7 +10,8 @@ import zipfile
# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
0x01010024: 'value', 0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion'}
0x01010024: 'value', 0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion',
0x01010202: 'targetActivity'}
T_REF, T_STRING, T_INT_DEC, T_INT_HEX = 0x01, 0x03, 0x10, 0x11
# APKs can come from websites (install links), so nothing read from one may be
# unbounded. zipfile stops at a member's declared size, so checking it is enough.
+9 -2
View File
@@ -10,13 +10,18 @@ VR = {'com.oculus.intent.category.VR', 'org.khronos.openxr.intent.category.IMMER
def inspect(data):
elements = iter(frame_apk.manifest_elements(data))
stack, filters, samsung = [], [], False
current = None
current, owner, package = None, None, ''
for kind, hs, off, size in frame_apk._chunks(data, 8, len(data)):
if kind == 0x0102:
tag, attrs = next(elements)
value = attrs.get('name', (None, None, None))[2]
if tag == 'manifest':
package = attrs.get('package', (None, None, None))[2] or ''
if tag in ('activity', 'activity-alias'):
owner = attrs.get('targetActivity', (None, None, None))[2] if tag == 'activity-alias' else value
owner = package + owner if owner and owner.startswith('.') else owner
if tag == 'intent-filter' and stack and stack[-1] in ('activity', 'activity-alias'):
current = {'actions': set(), 'categories': set(), 'templates': [], 'alias': stack[-1] == 'activity-alias'}
current = {'actions': set(), 'categories': set(), 'templates': [], 'alias': stack[-1] == 'activity-alias', 'activity': owner}
if current is not None and stack and stack[-1] == 'intent-filter':
if tag == 'action':
current['actions'].add(value)
@@ -38,7 +43,9 @@ def inspect(data):
real = [f for f in mains if not f['alias']]
targets = [f for f in real if VR & f['categories']]
if not targets and any(LAUNCHER in f['categories'] or VR & f['categories'] for f in mains if f['alias']):
aimed = {f['activity'] for f in mains if f['alias'] and (LAUNCHER in f['categories'] or VR & f['categories'])}
targets = [f for f in real if f['templates']] # the patch copies an existing <category>
targets = [f for f in targets if f['activity'] in aimed] or targets
launchable = any(LAUNCHER in f['categories'] for f in real)
return {'launchable': launchable, 'repairable': not launchable and bool(targets),
'vr_activity': bool(vr_filters), 'vr': bool(vr_filters) or samsung}, targets