Skip to content
Open
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
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
"import": "./dist/src/install-source.js",
"types": "./dist/src/install-source.d.ts"
},
"./android-apps": {
"import": "./dist/src/android-apps.js",
"types": "./dist/src/android-apps.d.ts"
},
"./contracts": {
"import": "./dist/src/contracts.js",
"types": "./dist/src/contracts.d.ts"
Expand Down
1 change: 1 addition & 0 deletions rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default defineConfig({
metro: 'src/metro.ts',
'remote-config': 'src/remote-config.ts',
'install-source': 'src/install-source.ts',
'android-apps': 'src/android-apps.ts',
contracts: 'src/contracts.ts',
selectors: 'src/selectors.ts',
finders: 'src/finders.ts',
Expand Down
73 changes: 73 additions & 0 deletions src/__tests__/android-apps-public.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';

import {
parseAndroidForegroundApp,
parseAndroidLaunchablePackages,
parseAndroidUserInstalledPackages,
} from '../android-apps.ts';

test('public android-apps entrypoint re-exports pure parsers', () => {
assert.deepEqual(
parseAndroidLaunchablePackages(
[
'com.google.android.apps.maps/.MainActivity',
'org.mozilla.firefox/.App',
'com.google.android.apps.maps/.MainActivity',
'',
].join('\n'),
),
['com.google.android.apps.maps', 'org.mozilla.firefox'],
);
assert.deepEqual(
parseAndroidUserInstalledPackages(
['package:com.google.android.apps.maps', 'package:org.mozilla.firefox', ''].join('\n'),
),
['com.google.android.apps.maps', 'org.mozilla.firefox'],
);
assert.deepEqual(parseAndroidUserInstalledPackages('package:com.example\nraw.package'), [
'com.example',
'raw.package',
]);
assert.deepEqual(
parseAndroidForegroundApp(
[
'mResumedActivity: ActivityRecord{123 u0 com.example.old/.OldActivity t1}',
'mCurrentFocus=Window{17b u0 com.google.android.apps.maps/.MainActivity}',
].join('\n'),
),
{
package: 'com.google.android.apps.maps',
activity: '.MainActivity',
},
);
assert.deepEqual(
parseAndroidForegroundApp(
'mFocusedApp=AppWindowToken{17b token=Token{abc ActivityRecord{def u0 org.mozilla.firefox/.App t1}}}',
),
{
package: 'org.mozilla.firefox',
activity: '.App',
},
);
assert.deepEqual(
parseAndroidForegroundApp(
'mResumedActivity: ActivityRecord{123 u0 com.example.app/com.example.app.MainActivity t1}',
),
{
package: 'com.example.app',
activity: 'com.example.app.MainActivity',
},
);
assert.deepEqual(
parseAndroidForegroundApp(
'ResumedActivity: ActivityRecord{123 link=https://example.test/path u0 com.example.next/.NextActivity t1}',
),
{
package: 'com.example.next',
activity: '.NextActivity',
},
);
assert.equal(parseAndroidForegroundApp('mCurrentFocus=Window{17b u0 no component here}'), null);
assert.equal(parseAndroidForegroundApp(''), null);
});
6 changes: 6 additions & 0 deletions src/android-apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
parseAndroidForegroundApp,
parseAndroidLaunchablePackages,
parseAndroidUserInstalledPackages,
type AndroidForegroundApp,
} from './platforms/android/app-parsers.ts';
50 changes: 19 additions & 31 deletions src/platforms/android/app-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ import { waitForAndroidBoot } from './devices.ts';
import { adbArgs } from './adb.ts';
import { classifyAndroidAppTarget } from './open-target.ts';
import { prepareAndroidInstallArtifact } from './install-artifact.ts';
import {
parseAndroidForegroundApp,
parseAndroidLaunchablePackages,
parseAndroidUserInstalledPackages,
type AndroidForegroundApp,
} from './app-parsers.ts';

export {
parseAndroidForegroundApp,
parseAndroidLaunchablePackages,
parseAndroidUserInstalledPackages,
type AndroidForegroundApp,
} from './app-parsers.ts';

const ALIASES: Record<string, { type: 'intent' | 'package'; value: string }> = {
settings: { type: 'intent', value: 'android.settings.SETTINGS' },
Expand Down Expand Up @@ -93,12 +106,8 @@ async function listAndroidLaunchablePackages(device: DeviceInfo): Promise<Set<st
if (result.exitCode !== 0 || result.stdout.trim().length === 0) {
continue;
}
for (const line of result.stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const firstToken = trimmed.split(/\s+/)[0];
const pkg = firstToken.includes('/') ? firstToken.split('/')[0] : firstToken;
if (pkg) packages.add(pkg);
for (const pkg of parseAndroidLaunchablePackages(result.stdout)) {
packages.add(pkg);
}
}
return packages;
Expand Down Expand Up @@ -126,10 +135,7 @@ function resolveAndroidLaunchCategories(

async function listAndroidUserInstalledPackages(device: DeviceInfo): Promise<string[]> {
const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'list', 'packages', '-3']));
return result.stdout
.split('\n')
.map((line: string) => line.replace('package:', '').trim())
.filter(Boolean);
return parseAndroidUserInstalledPackages(result.stdout);
}

export function inferAndroidAppName(packageName: string): string {
Expand Down Expand Up @@ -165,9 +171,7 @@ export function inferAndroidAppName(packageName: string): string {
.join(' ');
}

export async function getAndroidAppState(
device: DeviceInfo,
): Promise<{ package?: string; activity?: string }> {
export async function getAndroidAppState(device: DeviceInfo): Promise<AndroidForegroundApp> {
const windowFocus = await readAndroidFocus(device, [
['shell', 'dumpsys', 'window', 'windows'],
['shell', 'dumpsys', 'window'],
Expand All @@ -185,32 +189,16 @@ export async function getAndroidAppState(
async function readAndroidFocus(
device: DeviceInfo,
commands: string[][],
): Promise<{ package?: string; activity?: string } | null> {
): Promise<AndroidForegroundApp | null> {
for (const args of commands) {
const result = await runCmd('adb', adbArgs(device, args), { allowFailure: true });
const text = result.stdout ?? '';
const parsed = parseAndroidFocus(text);
const parsed = parseAndroidForegroundApp(text);
if (parsed) return parsed;
}
return null;
}

function parseAndroidFocus(text: string): { package?: string; activity?: string } | null {
const patterns = [
/mCurrentFocus=Window\{[^}]*\s([\w.]+)\/([\w.$]+)/,
/mFocusedApp=AppWindowToken\{[^}]*\s([\w.]+)\/([\w.$]+)/,
/mResumedActivity:.*?\s([\w.]+)\/([\w.$]+)/,
/ResumedActivity:.*?\s([\w.]+)\/([\w.$]+)/,
];
for (const pattern of patterns) {
const match = pattern.exec(text);
if (match) {
return { package: match[1], activity: match[2] };
}
}
return null;
}

export async function openAndroidApp(
device: DeviceInfo,
app: string,
Expand Down
79 changes: 79 additions & 0 deletions src/platforms/android/app-parsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
export type AndroidForegroundApp = { package?: string; activity?: string };

export function parseAndroidLaunchablePackages(stdout: string): string[] {
const packages = new Set<string>();
for (const line of stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const firstToken = trimmed.split(/\s+/)[0];
const pkg = firstToken.includes('/') ? firstToken.split('/')[0] : firstToken;
if (pkg) packages.add(pkg);
}
return Array.from(packages);
}

export function parseAndroidUserInstalledPackages(stdout: string): string[] {
return stdout
.split('\n')
.map((line: string) => {
const trimmed = line.trim();
return trimmed.startsWith('package:') ? trimmed.slice('package:'.length) : trimmed;
})
.filter(Boolean);
}

export function parseAndroidForegroundApp(text: string): AndroidForegroundApp | null {
const markers = [
'mCurrentFocus=Window{',
'mFocusedApp=AppWindowToken{',
'mResumedActivity:',
'ResumedActivity:',
];
const lines = text.split('\n');

for (const marker of markers) {
for (const line of lines) {
const markerIndex = line.indexOf(marker);
if (markerIndex === -1) continue;
const segment = line.slice(markerIndex + marker.length);
const parsed = parseAndroidComponentFromSegment(segment);
if (parsed) return parsed;
}
}
return null;
}

function parseAndroidComponentFromSegment(segment: string): AndroidForegroundApp | null {
for (const token of segment.trim().split(/\s+/)) {
const slashIndex = token.indexOf('/');
if (slashIndex <= 0) continue;

const packageName = readAndroidName(token.slice(0, slashIndex), false);
const activity = readAndroidName(token.slice(slashIndex + 1), true);
if (packageName && activity && packageName.length === slashIndex) {
return { package: packageName, activity };
}
}
return null;
}

function readAndroidName(value: string, allowDollar: boolean): string {
let index = 0;
while (index < value.length && isAndroidNameChar(value[index], allowDollar)) {
index += 1;
}
return value.slice(0, index);
}

function isAndroidNameChar(char: string | undefined, allowDollar: boolean): boolean {
if (!char) return false;
const code = char.charCodeAt(0);
return (
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
char === '_' ||
char === '.' ||
(allowDollar && char === '$')
);
}
Loading