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
55 changes: 55 additions & 0 deletions src/platforms/__tests__/app-resolution-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { createAppResolutionCache } from '../app-resolution-cache.ts';

test('app resolution cache returns values until the expiry boundary', () => {
let nowMs = 1_000;
const cache = createAppResolutionCache<string>({ ttlMs: 50, nowMs: () => nowMs });
const scope = { platform: 'android', deviceId: 'device-a' } as const;

assert.equal(cache.set(scope, 'Maps', 'com.example.maps'), 'com.example.maps');
assert.equal(cache.get(scope, 'maps'), 'com.example.maps');

nowMs = 1_049;
assert.equal(cache.get(scope, 'Maps'), 'com.example.maps');

nowMs = 1_050;
assert.equal(cache.get(scope, 'Maps'), undefined);
assert.equal(cache.get(scope, 'Maps'), undefined);
});

test('app resolution cache clear removes all variants for one device', () => {
const cache = createAppResolutionCache<string>({ nowMs: () => 0 });
const mobile = { platform: 'android', deviceId: 'device-a', variant: 'mobile' } as const;
const tv = { platform: 'android', deviceId: 'device-a', variant: 'tv' } as const;
const otherDevice = { platform: 'android', deviceId: 'device-b', variant: 'mobile' } as const;
const otherPlatform = { platform: 'ios', deviceId: 'device-a', variant: 'simulator' } as const;

cache.set(mobile, 'Maps', 'com.example.mobile.maps');
cache.set(tv, 'Maps', 'com.example.tv.maps');
cache.set(otherDevice, 'Maps', 'com.example.other.maps');
cache.set(otherPlatform, 'Maps', 'com.example.ios.maps');

cache.clear(mobile);

assert.equal(cache.get(mobile, 'Maps'), undefined);
assert.equal(cache.get(tv, 'Maps'), undefined);
assert.equal(cache.get(otherDevice, 'Maps'), 'com.example.other.maps');
assert.equal(cache.get(otherPlatform, 'Maps'), 'com.example.ios.maps');
});

test('app resolution cache invalidates before and after an operation', async () => {
const cache = createAppResolutionCache<string>({ nowMs: () => 0 });
const scope = { platform: 'ios', deviceId: 'device-a', variant: 'simulator' } as const;

cache.set(scope, 'Maps', 'com.example.before');

const result = await cache.invalidateWhile(scope, async () => {
assert.equal(cache.get(scope, 'Maps'), undefined);
cache.set(scope, 'Maps', 'com.example.during');
return 'installed';
});

assert.equal(result, 'installed');
assert.equal(cache.get(scope, 'Maps'), undefined);
});
77 changes: 77 additions & 0 deletions src/platforms/android/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getAndroidKeyboardState,
inferAndroidAppName,
installAndroidApp,
installAndroidInstallablePath,
isAmStartError,
listAndroidApps,
openAndroidApp,
Expand Down Expand Up @@ -978,6 +979,82 @@ test('resolveAndroidApp does not treat file paths as package names', async () =>
);
});

test('resolveAndroidApp caches display-name package matches but bypasses exact package ids', async () => {
await withMockedAdb(
'agent-device-android-resolve-cache-',
[
'#!/bin/sh',
'printf "%s\\n" "$*" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'if [ "$1" = "-s" ]; then shift; shift; fi',
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ] && [ "$4" = "packages" ]; then',
' echo "package:com.example.cachemaps"',
' exit 0',
'fi',
'exit 1',
'',
].join('\n'),
async ({ argsLogPath, device }) => {
const first = await resolveAndroidApp(device, 'cachemaps');
const second = await resolveAndroidApp(device, 'cachemaps');
const exact = await resolveAndroidApp(device, 'com.example.cachemaps');

assert.deepEqual(first, { type: 'package', value: 'com.example.cachemaps' });
assert.deepEqual(second, first);
assert.deepEqual(exact, { type: 'package', value: 'com.example.cachemaps' });

const logged = await fs.readFile(argsLogPath, 'utf8');
assert.equal((logged.match(/pm list packages/g) ?? []).length, 1);
},
);
});

test('installAndroidInstallablePath invalidates cached display-name package matches', async () => {
await withMockedAdb(
'agent-device-android-install-cache-',
[
'#!/bin/sh',
'printf "%s\\n" "$*" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'if [ "$1" = "-s" ]; then shift; shift; fi',
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ] && [ "$4" = "packages" ]; then',
' if [ -f "$AGENT_DEVICE_TEST_INSTALL_MARKER" ]; then',
' echo "package:com.example.installedcachemaps"',
' else',
' echo "package:com.example.cachemaps"',
' fi',
' exit 0',
'fi',
'if [ "$1" = "install" ] && [ "$2" = "-r" ]; then',
' : > "$AGENT_DEVICE_TEST_INSTALL_MARKER"',
' exit 0',
'fi',
'exit 1',
'',
].join('\n'),
async ({ device }) => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-cache-apk-'));
const apkPath = path.join(tmpDir, 'App.apk');
const previousMarker = process.env.AGENT_DEVICE_TEST_INSTALL_MARKER;
process.env.AGENT_DEVICE_TEST_INSTALL_MARKER = path.join(tmpDir, 'installed.marker');
try {
await fs.writeFile(apkPath, '', 'utf8');
const before = await resolveAndroidApp(device, 'cachemaps');
await installAndroidInstallablePath(device, apkPath);
const after = await resolveAndroidApp(device, 'cachemaps');

assert.deepEqual(before, { type: 'package', value: 'com.example.cachemaps' });
assert.deepEqual(after, { type: 'package', value: 'com.example.installedcachemaps' });
} finally {
if (previousMarker === undefined) {
delete process.env.AGENT_DEVICE_TEST_INSTALL_MARKER;
} else {
process.env.AGENT_DEVICE_TEST_INSTALL_MARKER = previousMarker;
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
},
);
});

test('openAndroidApp default launch uses -p package flag', async () => {
await withMockedAdb(
'agent-device-android-open-default-',
Expand Down
61 changes: 42 additions & 19 deletions src/platforms/android/app-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { resolveFileOverridePath, runCmd, whichCmd } from '../../utils/exec.ts';
import { AppError } from '../../utils/errors.ts';
import type { DeviceInfo } from '../../utils/device.ts';
import { isDeepLinkTarget } from '../../core/open-target.ts';
import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts';
import { waitForAndroidBoot } from './devices.ts';
import { adbArgs } from './adb.ts';
import { classifyAndroidAppTarget } from './open-target.ts';
Expand Down Expand Up @@ -34,16 +35,28 @@ const ANDROID_APPS_DISCOVERY_HINT =
const ANDROID_AMBIGUOUS_APP_HINT =
'Run agent-device apps --platform android to see the exact installed package names before retrying open.';

type AndroidAppResolution = { type: 'intent' | 'package'; value: string };

const androidAppResolutionCache = createAppResolutionCache<AndroidAppResolution>();

function androidAppResolutionScope(device: DeviceInfo): AppResolutionCacheScope {
return { platform: 'android', deviceId: device.id, variant: device.target ?? '' };
}

export async function resolveAndroidApp(
device: DeviceInfo,
app: string,
): Promise<{ type: 'intent' | 'package'; value: string }> {
): Promise<AndroidAppResolution> {
const trimmed = app.trim();
if (classifyAndroidAppTarget(trimmed) === 'package') return { type: 'package', value: trimmed };

const alias = ALIASES[trimmed.toLowerCase()];
if (alias) return alias;

const cacheScope = androidAppResolutionScope(device);
const cached = androidAppResolutionCache.get(cacheScope, trimmed);
if (cached) return cached;

const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'list', 'packages']));
const packages = result.stdout
.split('\n')
Expand All @@ -54,7 +67,10 @@ export async function resolveAndroidApp(
pkg.toLowerCase().includes(trimmed.toLowerCase()),
);
if (matches.length === 1) {
return { type: 'package', value: matches[0] };
return androidAppResolutionCache.set(cacheScope, trimmed, {
type: 'package',
value: matches[0],
});
}

if (matches.length > 1) {
Expand Down Expand Up @@ -560,10 +576,12 @@ export async function installAndroidInstallablePath(
device: DeviceInfo,
installablePath: string,
): Promise<void> {
if (!device.booted) {
await waitForAndroidBoot(device.id);
}
await installAndroidAppFiles(device, installablePath);
await androidAppResolutionCache.invalidateWhile(androidAppResolutionScope(device), async () => {
if (!device.booted) {
await waitForAndroidBoot(device.id);
}
await installAndroidAppFiles(device, installablePath);
});
}

export async function installAndroidInstallablePathAndResolvePackageName(
Expand Down Expand Up @@ -617,18 +635,23 @@ export async function reinstallAndroidApp(
app: string,
appPath: string,
): Promise<{ package: string }> {
if (!device.booted) {
await waitForAndroidBoot(device.id);
}
const { package: pkg } = await uninstallAndroidApp(device, app);
const prepared = await prepareAndroidInstallArtifact(
{ kind: 'path', path: appPath },
{ resolveIdentity: false },
return await androidAppResolutionCache.invalidateWhile(
androidAppResolutionScope(device),
async () => {
if (!device.booted) {
await waitForAndroidBoot(device.id);
}
const { package: pkg } = await uninstallAndroidApp(device, app);
const prepared = await prepareAndroidInstallArtifact(
{ kind: 'path', path: appPath },
{ resolveIdentity: false },
);
try {
await installAndroidInstallablePath(device, prepared.installablePath);
} finally {
await prepared.cleanup();
}
return { package: pkg };
},
);
try {
await installAndroidInstallablePath(device, prepared.installablePath);
return { package: pkg };
} finally {
await prepared.cleanup();
}
}
80 changes: 80 additions & 0 deletions src/platforms/app-resolution-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const APP_RESOLUTION_CACHE_TTL_MS = 30_000;

export type AppResolutionCacheScope = {
platform: 'android' | 'ios' | 'macos';
deviceId: string;
variant?: string;
};

type AppResolutionCacheEntry<T> = {
value: T;
expiresAtMs: number;
};

type AppResolutionCache<T> = {
get(scope: AppResolutionCacheScope, target: string): T | undefined;
set(scope: AppResolutionCacheScope, target: string, value: T): T;
clear(scope: AppResolutionCacheScope): void;
invalidateWhile<Result>(
scope: AppResolutionCacheScope,
operation: () => Promise<Result>,
): Promise<Result>;
};

export function createAppResolutionCache<T>(
options: { ttlMs?: number; nowMs?: () => number } = {},
): AppResolutionCache<T> {
const ttlMs = options.ttlMs ?? APP_RESOLUTION_CACHE_TTL_MS;
const nowMs = options.nowMs ?? Date.now;
const entries = new Map<string, AppResolutionCacheEntry<T>>();
const clearScope = (scope: AppResolutionCacheScope): void => {
const prefix = buildAppResolutionCacheScopePrefix(scope);
for (const key of entries.keys()) {
if (key.startsWith(prefix)) {
entries.delete(key);
}
}
};

return {
get(scope, target) {
const key = buildAppResolutionCacheKey(scope, target);
const entry = entries.get(key);
if (!entry) return undefined;
if (entry.expiresAtMs <= nowMs()) {
entries.delete(key);
return undefined;
}
return entry.value;
},
set(scope, target, value) {
entries.set(buildAppResolutionCacheKey(scope, target), {
value,
expiresAtMs: nowMs() + ttlMs,
});
return value;
},
clear(scope) {
clearScope(scope);
},
async invalidateWhile(scope, operation) {
clearScope(scope);
try {
return await operation();
} finally {
// A concurrent name lookup can finish after the initial clear and repopulate stale data.
clearScope(scope);
}
},
};
}

function buildAppResolutionCacheKey(scope: AppResolutionCacheScope, target: string): string {
return [scope.platform, scope.deviceId, scope.variant ?? '', target.trim().toLowerCase()].join(
'\0',
);
}

function buildAppResolutionCacheScopePrefix(scope: AppResolutionCacheScope): string {
return [scope.platform, scope.deviceId, ''].join('\0');
}
Loading
Loading