Skip to content

Commit c24b208

Browse files
committed
feat: export Android app parsers
1 parent 657945d commit c24b208

6 files changed

Lines changed: 166 additions & 31 deletions

File tree

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: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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(
29+
parseAndroidForegroundApp(
30+
[
31+
'mResumedActivity: ActivityRecord{123 u0 com.example.old/.OldActivity t1}',
32+
'mCurrentFocus=Window{17b u0 com.google.android.apps.maps/.MainActivity}',
33+
].join('\n'),
34+
),
35+
{
36+
package: 'com.google.android.apps.maps',
37+
activity: '.MainActivity',
38+
},
39+
);
40+
assert.deepEqual(
41+
parseAndroidForegroundApp(
42+
'mFocusedApp=AppWindowToken{17b token=Token{abc ActivityRecord{def u0 org.mozilla.firefox/.App t1}}}',
43+
),
44+
{
45+
package: 'org.mozilla.firefox',
46+
activity: '.App',
47+
},
48+
);
49+
assert.deepEqual(
50+
parseAndroidForegroundApp(
51+
'mResumedActivity: ActivityRecord{123 u0 com.example.app/com.example.app.MainActivity t1}',
52+
),
53+
{
54+
package: 'com.example.app',
55+
activity: 'com.example.app.MainActivity',
56+
},
57+
);
58+
});

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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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) => line.replace('package:', '').trim())
19+
.filter(Boolean);
20+
}
21+
22+
export function parseAndroidForegroundApp(text: string): AndroidForegroundApp | null {
23+
const markers = [
24+
'mCurrentFocus=Window{',
25+
'mFocusedApp=AppWindowToken{',
26+
'mResumedActivity:',
27+
'ResumedActivity:',
28+
];
29+
30+
for (const marker of markers) {
31+
let searchStart = 0;
32+
while (searchStart < text.length) {
33+
const markerIndex = text.indexOf(marker, searchStart);
34+
if (markerIndex === -1) break;
35+
const lineEnd = text.indexOf('\n', markerIndex);
36+
const segment = text.slice(markerIndex + marker.length, lineEnd === -1 ? undefined : lineEnd);
37+
const parsed = parseAndroidComponentFromSegment(segment);
38+
if (parsed) return parsed;
39+
searchStart = markerIndex + marker.length;
40+
}
41+
}
42+
return null;
43+
}
44+
45+
function parseAndroidComponentFromSegment(segment: string): AndroidForegroundApp | null {
46+
for (const token of segment.trim().split(/\s+/)) {
47+
const slashIndex = token.indexOf('/');
48+
if (slashIndex <= 0) continue;
49+
50+
const packageName = readAndroidName(token.slice(0, slashIndex), false);
51+
const activity = readAndroidName(token.slice(slashIndex + 1), true);
52+
if (packageName && activity && packageName.length === slashIndex) {
53+
return { package: packageName, activity };
54+
}
55+
}
56+
return null;
57+
}
58+
59+
function readAndroidName(value: string, allowDollar: boolean): string {
60+
let index = 0;
61+
while (index < value.length && isAndroidNameChar(value[index], allowDollar)) {
62+
index += 1;
63+
}
64+
return value.slice(0, index);
65+
}
66+
67+
function isAndroidNameChar(char: string | undefined, allowDollar: boolean): boolean {
68+
if (!char) return false;
69+
const code = char.charCodeAt(0);
70+
return (
71+
(code >= 48 && code <= 57) ||
72+
(code >= 65 && code <= 90) ||
73+
(code >= 97 && code <= 122) ||
74+
char === '_' ||
75+
char === '.' ||
76+
(allowDollar && char === '$')
77+
);
78+
}

0 commit comments

Comments
 (0)