Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/platforms/android/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import os from 'node:os';
import path from 'node:path';
import {
inferAndroidAppName,
isAmStartError,
listAndroidApps,
openAndroidApp,
parseAndroidLaunchComponent,
Expand Down Expand Up @@ -139,6 +140,23 @@ test('parseAndroidLaunchComponent returns null when no component is present', ()
assert.equal(parseAndroidLaunchComponent(stdout), null);
});

test('isAmStartError detects am start failure in stdout', () => {
assert.equal(
isAmStartError(
'Starting: Intent { ... }\nError: Activity not started, unable to resolve Intent { ... }',
'',
),
true,
);
});

test('isAmStartError returns false for successful am start', () => {
assert.equal(
isAmStartError('Status: ok\nLaunchState: COLD\nActivity: com.example/.MainActivity', ''),
false,
);
});

test('inferAndroidAppName derives readable names from package ids', () => {
assert.equal(inferAndroidAppName('com.android.settings'), 'Settings');
assert.equal(inferAndroidAppName('com.google.android.apps.maps'), 'Maps');
Expand Down Expand Up @@ -317,6 +335,96 @@ test('swipeAndroid invokes adb input swipe with duration', async () => {
}
});

test('openAndroidApp default launch uses -p package flag', async () => {
await withMockedAdb(
'agent-device-android-open-default-',
[
'#!/bin/sh',
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'if [ "$1" = "-s" ]; then',
' shift',
' shift',
'fi',
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ]; then',
' echo "package:com.example.app"',
' exit 0',
'fi',
'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then',
' echo "Status: ok"',
' exit 0',
'fi',
'exit 0',
'',
].join('\n'),
async ({ argsLogPath, device }) => {
await openAndroidApp(device, 'com.example.app');
const logged = await fs.readFile(argsLogPath, 'utf8');
assert.match(logged, /shell\nam\nstart\n-W\n-a\nandroid\.intent\.action\.MAIN/);
assert.match(logged, /-p\ncom\.example\.app/);
},
);
});

test('openAndroidApp fallback resolve-activity includes MAIN/LAUNCHER flags', async () => {
await withMockedAdb(
'agent-device-android-open-fallback-',
[
'#!/bin/sh',
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'if [ "$1" = "-s" ]; then',
' shift',
' shift',
'fi',
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ]; then',
' echo "package:com.microsoft.office.outlook"',
' exit 0',
'fi',
'# First am start (with -p) outputs error but exits 0 (real Android behavior)',
'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then',
' for arg in "$@"; do',
' if [ "$arg" = "-p" ]; then',
' echo "Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.DEFAULT,android.intent.category.LAUNCHER] pkg=com.microsoft.office.outlook }"',
' 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 }"',
' exit 0',
' fi',
' done',
' echo "Status: ok"',
' exit 0',
'fi',
'# resolve-activity returns correct launcher component',
'if [ "$1" = "shell" ] && [ "$2" = "cmd" ] && [ "$3" = "package" ] && [ "$4" = "resolve-activity" ]; then',
' echo "priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true"',
' echo "com.microsoft.office.outlook/com.microsoft.office.outlook.ui.miit.MiitLauncherActivity"',
' exit 0',
'fi',
'exit 0',
'',
].join('\n'),
async ({ argsLogPath, device }) => {
await openAndroidApp(device, 'com.microsoft.office.outlook');
const logged = await fs.readFile(argsLogPath, 'utf8');
// Verify resolve-activity was called with MAIN/LAUNCHER flags
assert.match(logged, /resolve-activity\n--brief\n-a\nandroid\.intent\.action\.MAIN\n-c\nandroid\.intent\.category\.LAUNCHER\ncom\.microsoft\.office\.outlook/);
// Verify fallback launch used the resolved component
assert.match(logged, /-n\ncom\.microsoft\.office\.outlook\/com\.microsoft\.office\.outlook\.ui\.miit\.MiitLauncherActivity/);
},
);
});

test('parseAndroidLaunchComponent handles multi-entry resolve output', () => {
// Some devices return extra metadata lines before the component
const stdout = [
'priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true',
'com.microsoft.office.outlook/com.microsoft.office.outlook.ui.miit.MiitLauncherActivity',
].join('\n');
assert.equal(
parseAndroidLaunchComponent(stdout),
'com.microsoft.office.outlook/com.microsoft.office.outlook.ui.miit.MiitLauncherActivity',
);
});

test('setAndroidSetting permission grant camera uses pm grant', async () => {
await withMockedAdb(
'agent-device-android-permission-camera-',
Expand Down
99 changes: 60 additions & 39 deletions src/platforms/android/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,46 +238,51 @@ export async function openAndroidApp(
);
return;
}
try {
await runCmd(
'adb',
adbArgs(device, [
'shell',
'am',
'start',
'-W',
'-a',
'android.intent.action.MAIN',
'-c',
'android.intent.category.DEFAULT',
'-c',
'android.intent.category.LAUNCHER',
'-p',
resolved.value,
]),
);
const primaryResult = await runCmd(
'adb',
adbArgs(device, [
'shell',
'am',
'start',
'-W',
'-a',
'android.intent.action.MAIN',
'-c',
'android.intent.category.DEFAULT',
'-c',
'android.intent.category.LAUNCHER',
'-p',
resolved.value,
]),
{ allowFailure: true },
);
if (primaryResult.exitCode === 0 && !isAmStartError(primaryResult.stdout, primaryResult.stderr)) {
return;
} catch (initialError) {
const component = await resolveAndroidLaunchComponent(device, resolved.value);
if (!component) throw initialError;
await runCmd(
'adb',
adbArgs(device, [
'shell',
'am',
'start',
'-W',
'-a',
'android.intent.action.MAIN',
'-c',
'android.intent.category.DEFAULT',
'-c',
'android.intent.category.LAUNCHER',
'-n',
component,
]),
);
}
const component = await resolveAndroidLaunchComponent(device, resolved.value);
if (!component) {
throw new AppError('COMMAND_FAILED', `Failed to launch ${resolved.value}`, {
stdout: primaryResult.stdout,
stderr: primaryResult.stderr,
});
}
await runCmd(
'adb',
adbArgs(device, [
'shell',
'am',
'start',
'-W',
'-a',
'android.intent.action.MAIN',
'-c',
'android.intent.category.DEFAULT',
'-c',
'android.intent.category.LAUNCHER',
'-n',
component,
]),
);
}

async function resolveAndroidLaunchComponent(
Expand All @@ -286,13 +291,29 @@ async function resolveAndroidLaunchComponent(
): Promise<string | null> {
const result = await runCmd(
'adb',
adbArgs(device, ['shell', 'cmd', 'package', 'resolve-activity', '--brief', packageName]),
adbArgs(device, [
'shell',
'cmd',
'package',
'resolve-activity',
'--brief',
'-a',
'android.intent.action.MAIN',
'-c',
'android.intent.category.LAUNCHER',
packageName,
]),
{ allowFailure: true },
);
if (result.exitCode !== 0) return null;
return parseAndroidLaunchComponent(result.stdout);
}

export function isAmStartError(stdout: string, stderr: string): boolean {
const output = `${stdout}\n${stderr}`;
return /Error:.*(?:Activity not started|unable to resolve Intent)/i.test(output);
}

export function parseAndroidLaunchComponent(stdout: string): string | null {
const lines = stdout
.split('\n')
Expand Down
Loading