Skip to content

Commit 39a09f9

Browse files
authored
feat: export Android app parsers (#406)
1 parent 47a1529 commit 39a09f9

File tree

6 files changed

+182
-31
lines changed

6 files changed

+182
-31
lines changed

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
"import": "./dist/src/install-source.js",
2929
"types": "./dist/src/install-source.d.ts"
3030
},
31+
"./android-apps": {
32+
"import": "./dist/src/android-apps.js",
33+
"types": "./dist/src/android-apps.d.ts"
34+
},
3135
"./contracts": {
3236
"import": "./dist/src/contracts.js",
3337
"types": "./dist/src/contracts.d.ts"

rslib.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default defineConfig({
2121
metro: 'src/metro.ts',
2222
'remote-config': 'src/remote-config.ts',
2323
'install-source': 'src/install-source.ts',
24+
'android-apps': 'src/android-apps.ts',
2425
contracts: 'src/contracts.ts',
2526
selectors: 'src/selectors.ts',
2627
finders: 'src/finders.ts',
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
4+
import {
5+
parseAndroidForegroundApp,
6+
parseAndroidLaunchablePackages,
7+
parseAndroidUserInstalledPackages,
8+
} from '../android-apps.ts';
9+
10+
test('public android-apps entrypoint re-exports pure parsers', () => {
11+
assert.deepEqual(
12+
parseAndroidLaunchablePackages(
13+
[
14+
'com.google.android.apps.maps/.MainActivity',
15+
'org.mozilla.firefox/.App',
16+
'com.google.android.apps.maps/.MainActivity',
17+
'',
18+
].join('\n'),
19+
),
20+
['com.google.android.apps.maps', 'org.mozilla.firefox'],
21+
);
22+
assert.deepEqual(
23+
parseAndroidUserInstalledPackages(
24+
['package:com.google.android.apps.maps', 'package:org.mozilla.firefox', ''].join('\n'),
25+
),
26+
['com.google.android.apps.maps', 'org.mozilla.firefox'],
27+
);
28+
assert.deepEqual(parseAndroidUserInstalledPackages('package:com.example\nraw.package'), [
29+
'com.example',
30+
'raw.package',
31+
]);
32+
assert.deepEqual(
33+
parseAndroidForegroundApp(
34+
[
35+
'mResumedActivity: ActivityRecord{123 u0 com.example.old/.OldActivity t1}',
36+
'mCurrentFocus=Window{17b u0 com.google.android.apps.maps/.MainActivity}',
37+
].join('\n'),
38+
),
39+
{
40+
package: 'com.google.android.apps.maps',
41+
activity: '.MainActivity',
42+
},
43+
);
44+
assert.deepEqual(
45+
parseAndroidForegroundApp(
46+
'mFocusedApp=AppWindowToken{17b token=Token{abc ActivityRecord{def u0 org.mozilla.firefox/.App t1}}}',
47+
),
48+
{
49+
package: 'org.mozilla.firefox',
50+
activity: '.App',
51+
},
52+
);
53+
assert.deepEqual(
54+
parseAndroidForegroundApp(
55+
'mResumedActivity: ActivityRecord{123 u0 com.example.app/com.example.app.MainActivity t1}',
56+
),
57+
{
58+
package: 'com.example.app',
59+
activity: 'com.example.app.MainActivity',
60+
},
61+
);
62+
assert.deepEqual(
63+
parseAndroidForegroundApp(
64+
'ResumedActivity: ActivityRecord{123 link=https://example.test/path u0 com.example.next/.NextActivity t1}',
65+
),
66+
{
67+
package: 'com.example.next',
68+
activity: '.NextActivity',
69+
},
70+
);
71+
assert.equal(parseAndroidForegroundApp('mCurrentFocus=Window{17b u0 no component here}'), null);
72+
assert.equal(parseAndroidForegroundApp(''), null);
73+
});

src/android-apps.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export {
2+
parseAndroidForegroundApp,
3+
parseAndroidLaunchablePackages,
4+
parseAndroidUserInstalledPackages,
5+
type AndroidForegroundApp,
6+
} from './platforms/android/app-parsers.ts';

src/platforms/android/app-lifecycle.ts

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ import { waitForAndroidBoot } from './devices.ts';
99
import { adbArgs } from './adb.ts';
1010
import { classifyAndroidAppTarget } from './open-target.ts';
1111
import { prepareAndroidInstallArtifact } from './install-artifact.ts';
12+
import {
13+
parseAndroidForegroundApp,
14+
parseAndroidLaunchablePackages,
15+
parseAndroidUserInstalledPackages,
16+
type AndroidForegroundApp,
17+
} from './app-parsers.ts';
18+
19+
export {
20+
parseAndroidForegroundApp,
21+
parseAndroidLaunchablePackages,
22+
parseAndroidUserInstalledPackages,
23+
type AndroidForegroundApp,
24+
} from './app-parsers.ts';
1225

1326
const ALIASES: Record<string, { type: 'intent' | 'package'; value: string }> = {
1427
settings: { type: 'intent', value: 'android.settings.SETTINGS' },
@@ -93,12 +106,8 @@ async function listAndroidLaunchablePackages(device: DeviceInfo): Promise<Set<st
93106
if (result.exitCode !== 0 || result.stdout.trim().length === 0) {
94107
continue;
95108
}
96-
for (const line of result.stdout.split('\n')) {
97-
const trimmed = line.trim();
98-
if (!trimmed) continue;
99-
const firstToken = trimmed.split(/\s+/)[0];
100-
const pkg = firstToken.includes('/') ? firstToken.split('/')[0] : firstToken;
101-
if (pkg) packages.add(pkg);
109+
for (const pkg of parseAndroidLaunchablePackages(result.stdout)) {
110+
packages.add(pkg);
102111
}
103112
}
104113
return packages;
@@ -126,10 +135,7 @@ function resolveAndroidLaunchCategories(
126135

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

135141
export function inferAndroidAppName(packageName: string): string {
@@ -165,9 +171,7 @@ export function inferAndroidAppName(packageName: string): string {
165171
.join(' ');
166172
}
167173

168-
export async function getAndroidAppState(
169-
device: DeviceInfo,
170-
): Promise<{ package?: string; activity?: string }> {
174+
export async function getAndroidAppState(device: DeviceInfo): Promise<AndroidForegroundApp> {
171175
const windowFocus = await readAndroidFocus(device, [
172176
['shell', 'dumpsys', 'window', 'windows'],
173177
['shell', 'dumpsys', 'window'],
@@ -185,32 +189,16 @@ export async function getAndroidAppState(
185189
async function readAndroidFocus(
186190
device: DeviceInfo,
187191
commands: string[][],
188-
): Promise<{ package?: string; activity?: string } | null> {
192+
): Promise<AndroidForegroundApp | null> {
189193
for (const args of commands) {
190194
const result = await runCmd('adb', adbArgs(device, args), { allowFailure: true });
191195
const text = result.stdout ?? '';
192-
const parsed = parseAndroidFocus(text);
196+
const parsed = parseAndroidForegroundApp(text);
193197
if (parsed) return parsed;
194198
}
195199
return null;
196200
}
197201

198-
function parseAndroidFocus(text: string): { package?: string; activity?: string } | null {
199-
const patterns = [
200-
/mCurrentFocus=Window\{[^}]*\s([\w.]+)\/([\w.$]+)/,
201-
/mFocusedApp=AppWindowToken\{[^}]*\s([\w.]+)\/([\w.$]+)/,
202-
/mResumedActivity:.*?\s([\w.]+)\/([\w.$]+)/,
203-
/ResumedActivity:.*?\s([\w.]+)\/([\w.$]+)/,
204-
];
205-
for (const pattern of patterns) {
206-
const match = pattern.exec(text);
207-
if (match) {
208-
return { package: match[1], activity: match[2] };
209-
}
210-
}
211-
return null;
212-
}
213-
214202
export async function openAndroidApp(
215203
device: DeviceInfo,
216204
app: string,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
export type AndroidForegroundApp = { package?: string; activity?: string };
2+
3+
export function parseAndroidLaunchablePackages(stdout: string): string[] {
4+
const packages = new Set<string>();
5+
for (const line of stdout.split('\n')) {
6+
const trimmed = line.trim();
7+
if (!trimmed) continue;
8+
const firstToken = trimmed.split(/\s+/)[0];
9+
const pkg = firstToken.includes('/') ? firstToken.split('/')[0] : firstToken;
10+
if (pkg) packages.add(pkg);
11+
}
12+
return Array.from(packages);
13+
}
14+
15+
export function parseAndroidUserInstalledPackages(stdout: string): string[] {
16+
return stdout
17+
.split('\n')
18+
.map((line: string) => {
19+
const trimmed = line.trim();
20+
return trimmed.startsWith('package:') ? trimmed.slice('package:'.length) : trimmed;
21+
})
22+
.filter(Boolean);
23+
}
24+
25+
export function parseAndroidForegroundApp(text: string): AndroidForegroundApp | null {
26+
const markers = [
27+
'mCurrentFocus=Window{',
28+
'mFocusedApp=AppWindowToken{',
29+
'mResumedActivity:',
30+
'ResumedActivity:',
31+
];
32+
const lines = text.split('\n');
33+
34+
for (const marker of markers) {
35+
for (const line of lines) {
36+
const markerIndex = line.indexOf(marker);
37+
if (markerIndex === -1) continue;
38+
const segment = line.slice(markerIndex + marker.length);
39+
const parsed = parseAndroidComponentFromSegment(segment);
40+
if (parsed) return parsed;
41+
}
42+
}
43+
return null;
44+
}
45+
46+
function parseAndroidComponentFromSegment(segment: string): AndroidForegroundApp | null {
47+
for (const token of segment.trim().split(/\s+/)) {
48+
const slashIndex = token.indexOf('/');
49+
if (slashIndex <= 0) continue;
50+
51+
const packageName = readAndroidName(token.slice(0, slashIndex), false);
52+
const activity = readAndroidName(token.slice(slashIndex + 1), true);
53+
if (packageName && activity && packageName.length === slashIndex) {
54+
return { package: packageName, activity };
55+
}
56+
}
57+
return null;
58+
}
59+
60+
function readAndroidName(value: string, allowDollar: boolean): string {
61+
let index = 0;
62+
while (index < value.length && isAndroidNameChar(value[index], allowDollar)) {
63+
index += 1;
64+
}
65+
return value.slice(0, index);
66+
}
67+
68+
function isAndroidNameChar(char: string | undefined, allowDollar: boolean): boolean {
69+
if (!char) return false;
70+
const code = char.charCodeAt(0);
71+
return (
72+
(code >= 48 && code <= 57) ||
73+
(code >= 65 && code <= 90) ||
74+
(code >= 97 && code <= 122) ||
75+
char === '_' ||
76+
char === '.' ||
77+
(allowDollar && char === '$')
78+
);
79+
}

0 commit comments

Comments
 (0)