Skip to content

Commit 0041dee

Browse files
EarthXPclaude
andauthored
fix: add MAIN/LAUNCHER flags to Android launcher activity resolver (#115)
* fix: add MAIN/LAUNCHER flags to Android launcher activity resolver The `resolve-activity` fallback command was missing `-a android.intent.action.MAIN` and `-c android.intent.category.LAUNCHER`, causing it to resolve the wrong activity on multi-entry apps like Microsoft Outlook. This adds the correct intent flags so the resolver consistently returns the launcher activity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: detect am start silent failures and fall back to resolved component am start -p <package> can exit 0 while printing "Error: Activity not started" to stdout. The previous try/catch never entered the fallback because runCmd did not throw. Now we check the output for error indicators via isAmStartError() and proceed to the resolve-activity fallback when the primary launch silently fails. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: EarthXP <15072042+EarthXP@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 67b6698 commit 0041dee

2 files changed

Lines changed: 168 additions & 39 deletions

File tree

src/platforms/android/__tests__/index.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import os from 'node:os';
55
import path from 'node:path';
66
import {
77
inferAndroidAppName,
8+
isAmStartError,
89
listAndroidApps,
910
openAndroidApp,
1011
parseAndroidLaunchComponent,
@@ -139,6 +140,23 @@ test('parseAndroidLaunchComponent returns null when no component is present', ()
139140
assert.equal(parseAndroidLaunchComponent(stdout), null);
140141
});
141142

143+
test('isAmStartError detects am start failure in stdout', () => {
144+
assert.equal(
145+
isAmStartError(
146+
'Starting: Intent { ... }\nError: Activity not started, unable to resolve Intent { ... }',
147+
'',
148+
),
149+
true,
150+
);
151+
});
152+
153+
test('isAmStartError returns false for successful am start', () => {
154+
assert.equal(
155+
isAmStartError('Status: ok\nLaunchState: COLD\nActivity: com.example/.MainActivity', ''),
156+
false,
157+
);
158+
});
159+
142160
test('inferAndroidAppName derives readable names from package ids', () => {
143161
assert.equal(inferAndroidAppName('com.android.settings'), 'Settings');
144162
assert.equal(inferAndroidAppName('com.google.android.apps.maps'), 'Maps');
@@ -317,6 +335,96 @@ test('swipeAndroid invokes adb input swipe with duration', async () => {
317335
}
318336
});
319337

338+
test('openAndroidApp default launch uses -p package flag', async () => {
339+
await withMockedAdb(
340+
'agent-device-android-open-default-',
341+
[
342+
'#!/bin/sh',
343+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
344+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
345+
'if [ "$1" = "-s" ]; then',
346+
' shift',
347+
' shift',
348+
'fi',
349+
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ]; then',
350+
' echo "package:com.example.app"',
351+
' exit 0',
352+
'fi',
353+
'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then',
354+
' echo "Status: ok"',
355+
' exit 0',
356+
'fi',
357+
'exit 0',
358+
'',
359+
].join('\n'),
360+
async ({ argsLogPath, device }) => {
361+
await openAndroidApp(device, 'com.example.app');
362+
const logged = await fs.readFile(argsLogPath, 'utf8');
363+
assert.match(logged, /shell\nam\nstart\n-W\n-a\nandroid\.intent\.action\.MAIN/);
364+
assert.match(logged, /-p\ncom\.example\.app/);
365+
},
366+
);
367+
});
368+
369+
test('openAndroidApp fallback resolve-activity includes MAIN/LAUNCHER flags', async () => {
370+
await withMockedAdb(
371+
'agent-device-android-open-fallback-',
372+
[
373+
'#!/bin/sh',
374+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
375+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
376+
'if [ "$1" = "-s" ]; then',
377+
' shift',
378+
' shift',
379+
'fi',
380+
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ]; then',
381+
' echo "package:com.microsoft.office.outlook"',
382+
' exit 0',
383+
'fi',
384+
'# First am start (with -p) outputs error but exits 0 (real Android behavior)',
385+
'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then',
386+
' for arg in "$@"; do',
387+
' if [ "$arg" = "-p" ]; then',
388+
' echo "Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.DEFAULT,android.intent.category.LAUNCHER] pkg=com.microsoft.office.outlook }"',
389+
' echo "Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN cat=[android.intent.category.DEFAULT,android.intent.category.LAUNCHER] flg=0x10000000 pkg=com.microsoft.office.outlook }"',
390+
' exit 0',
391+
' fi',
392+
' done',
393+
' echo "Status: ok"',
394+
' exit 0',
395+
'fi',
396+
'# resolve-activity returns correct launcher component',
397+
'if [ "$1" = "shell" ] && [ "$2" = "cmd" ] && [ "$3" = "package" ] && [ "$4" = "resolve-activity" ]; then',
398+
' echo "priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true"',
399+
' echo "com.microsoft.office.outlook/com.microsoft.office.outlook.ui.miit.MiitLauncherActivity"',
400+
' exit 0',
401+
'fi',
402+
'exit 0',
403+
'',
404+
].join('\n'),
405+
async ({ argsLogPath, device }) => {
406+
await openAndroidApp(device, 'com.microsoft.office.outlook');
407+
const logged = await fs.readFile(argsLogPath, 'utf8');
408+
// Verify resolve-activity was called with MAIN/LAUNCHER flags
409+
assert.match(logged, /resolve-activity\n--brief\n-a\nandroid\.intent\.action\.MAIN\n-c\nandroid\.intent\.category\.LAUNCHER\ncom\.microsoft\.office\.outlook/);
410+
// Verify fallback launch used the resolved component
411+
assert.match(logged, /-n\ncom\.microsoft\.office\.outlook\/com\.microsoft\.office\.outlook\.ui\.miit\.MiitLauncherActivity/);
412+
},
413+
);
414+
});
415+
416+
test('parseAndroidLaunchComponent handles multi-entry resolve output', () => {
417+
// Some devices return extra metadata lines before the component
418+
const stdout = [
419+
'priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true',
420+
'com.microsoft.office.outlook/com.microsoft.office.outlook.ui.miit.MiitLauncherActivity',
421+
].join('\n');
422+
assert.equal(
423+
parseAndroidLaunchComponent(stdout),
424+
'com.microsoft.office.outlook/com.microsoft.office.outlook.ui.miit.MiitLauncherActivity',
425+
);
426+
});
427+
320428
test('setAndroidSetting permission grant camera uses pm grant', async () => {
321429
await withMockedAdb(
322430
'agent-device-android-permission-camera-',

src/platforms/android/index.ts

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -238,46 +238,51 @@ export async function openAndroidApp(
238238
);
239239
return;
240240
}
241-
try {
242-
await runCmd(
243-
'adb',
244-
adbArgs(device, [
245-
'shell',
246-
'am',
247-
'start',
248-
'-W',
249-
'-a',
250-
'android.intent.action.MAIN',
251-
'-c',
252-
'android.intent.category.DEFAULT',
253-
'-c',
254-
'android.intent.category.LAUNCHER',
255-
'-p',
256-
resolved.value,
257-
]),
258-
);
241+
const primaryResult = await runCmd(
242+
'adb',
243+
adbArgs(device, [
244+
'shell',
245+
'am',
246+
'start',
247+
'-W',
248+
'-a',
249+
'android.intent.action.MAIN',
250+
'-c',
251+
'android.intent.category.DEFAULT',
252+
'-c',
253+
'android.intent.category.LAUNCHER',
254+
'-p',
255+
resolved.value,
256+
]),
257+
{ allowFailure: true },
258+
);
259+
if (primaryResult.exitCode === 0 && !isAmStartError(primaryResult.stdout, primaryResult.stderr)) {
259260
return;
260-
} catch (initialError) {
261-
const component = await resolveAndroidLaunchComponent(device, resolved.value);
262-
if (!component) throw initialError;
263-
await runCmd(
264-
'adb',
265-
adbArgs(device, [
266-
'shell',
267-
'am',
268-
'start',
269-
'-W',
270-
'-a',
271-
'android.intent.action.MAIN',
272-
'-c',
273-
'android.intent.category.DEFAULT',
274-
'-c',
275-
'android.intent.category.LAUNCHER',
276-
'-n',
277-
component,
278-
]),
279-
);
280261
}
262+
const component = await resolveAndroidLaunchComponent(device, resolved.value);
263+
if (!component) {
264+
throw new AppError('COMMAND_FAILED', `Failed to launch ${resolved.value}`, {
265+
stdout: primaryResult.stdout,
266+
stderr: primaryResult.stderr,
267+
});
268+
}
269+
await runCmd(
270+
'adb',
271+
adbArgs(device, [
272+
'shell',
273+
'am',
274+
'start',
275+
'-W',
276+
'-a',
277+
'android.intent.action.MAIN',
278+
'-c',
279+
'android.intent.category.DEFAULT',
280+
'-c',
281+
'android.intent.category.LAUNCHER',
282+
'-n',
283+
component,
284+
]),
285+
);
281286
}
282287

283288
async function resolveAndroidLaunchComponent(
@@ -286,13 +291,29 @@ async function resolveAndroidLaunchComponent(
286291
): Promise<string | null> {
287292
const result = await runCmd(
288293
'adb',
289-
adbArgs(device, ['shell', 'cmd', 'package', 'resolve-activity', '--brief', packageName]),
294+
adbArgs(device, [
295+
'shell',
296+
'cmd',
297+
'package',
298+
'resolve-activity',
299+
'--brief',
300+
'-a',
301+
'android.intent.action.MAIN',
302+
'-c',
303+
'android.intent.category.LAUNCHER',
304+
packageName,
305+
]),
290306
{ allowFailure: true },
291307
);
292308
if (result.exitCode !== 0) return null;
293309
return parseAndroidLaunchComponent(result.stdout);
294310
}
295311

312+
export function isAmStartError(stdout: string, stderr: string): boolean {
313+
const output = `${stdout}\n${stderr}`;
314+
return /Error:.*(?:Activity not started|unable to resolve Intent)/i.test(output);
315+
}
316+
296317
export function parseAndroidLaunchComponent(stdout: string): string | null {
297318
const lines = stdout
298319
.split('\n')

0 commit comments

Comments
 (0)