diff --git a/package.json b/package.json index 8d42bec2..3bb2b059 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/rslib.config.ts b/rslib.config.ts index 9be0f798..d2d887d0 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -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', diff --git a/src/__tests__/android-apps-public.test.ts b/src/__tests__/android-apps-public.test.ts new file mode 100644 index 00000000..7e3fd3d0 --- /dev/null +++ b/src/__tests__/android-apps-public.test.ts @@ -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); +}); diff --git a/src/android-apps.ts b/src/android-apps.ts new file mode 100644 index 00000000..52ebcee3 --- /dev/null +++ b/src/android-apps.ts @@ -0,0 +1,6 @@ +export { + parseAndroidForegroundApp, + parseAndroidLaunchablePackages, + parseAndroidUserInstalledPackages, + type AndroidForegroundApp, +} from './platforms/android/app-parsers.ts'; diff --git a/src/platforms/android/app-lifecycle.ts b/src/platforms/android/app-lifecycle.ts index 1d5153d4..33460f39 100644 --- a/src/platforms/android/app-lifecycle.ts +++ b/src/platforms/android/app-lifecycle.ts @@ -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 = { settings: { type: 'intent', value: 'android.settings.SETTINGS' }, @@ -93,12 +106,8 @@ async function listAndroidLaunchablePackages(device: DeviceInfo): Promise { 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 { @@ -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 { const windowFocus = await readAndroidFocus(device, [ ['shell', 'dumpsys', 'window', 'windows'], ['shell', 'dumpsys', 'window'], @@ -185,32 +189,16 @@ export async function getAndroidAppState( async function readAndroidFocus( device: DeviceInfo, commands: string[][], -): Promise<{ package?: string; activity?: string } | null> { +): Promise { 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, diff --git a/src/platforms/android/app-parsers.ts b/src/platforms/android/app-parsers.ts new file mode 100644 index 00000000..4c3ce406 --- /dev/null +++ b/src/platforms/android/app-parsers.ts @@ -0,0 +1,79 @@ +export type AndroidForegroundApp = { package?: string; activity?: string }; + +export function parseAndroidLaunchablePackages(stdout: string): string[] { + const packages = new Set(); + 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 === '$') + ); +}