Skip to content
1 change: 1 addition & 0 deletions packages/contracts/src/application-lifecycle-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export type ApplicationLifecycleExecution = Readonly<{
/** Semantic target resolution used before an application open. */
export type OpenTargetResolutionInput = Readonly<{
target?: string;
launchArgs?: readonly string[];
currentAppBundleId?: string;
surface: SessionSurface;
/** `open --foreground` asks the admitted Apple binding to inspect its exact target. */
Expand Down
8 changes: 7 additions & 1 deletion packages/platform-android/src/app-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,13 @@ async function openAndroidIntent(
if (options.activity) {
throw new AppError('INVALID_ARGS', 'Activity override requires a package name, not an intent');
}
await runAndroidShell(device, ['am', 'start', '-W', '-a', intent, ...androidLaunchArgs(options)]);
const { buildAndroidIntentArgs } = await import('./intent-arguments.ts');
await runAndroidShell(device, [
'am',
'start',
'-W',
...buildAndroidIntentArgs(intent, options.launchArgs).launchArgs,
]);
}

async function openAndroidPackageActivity(
Expand Down
138 changes: 138 additions & 0 deletions packages/platform-android/src/intent-arguments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { expect, test } from 'vitest';
import fc from 'fast-check';
import { buildAndroidIntentArgs } from './intent-arguments.ts';

const ACTION_ARGS = ['-a', 'android.settings.SETTINGS'];
const PROPERTY_RUNS = 100;

test.each([
{
name: 'attached short values and extras resembling launch options',
args: ['-R2', '-ncom.example/.Main', '-ekey', '--display', '-P-S', '--esn', '-S', '-S'],
intentArgs: ['-ncom.example/.Main', '-ekey', '--display', '--esn', '-S'],
},
{
name: 'selector arguments',
args: ['-S', '--selector', '-a', 'com.example.VIEW', '--display', '0', '-p', 'com.example'],
intentArgs: ['--selector', '-a', 'com.example.VIEW', '-p', 'com.example'],
},
{
name: 'MIME type retained by AOSP across a selector',
args: ['-t', 'text/plain', '--selector', '-d', 'content://example/item', '-S'],
intentArgs: ['-t', 'text/plain', '--selector', '-d', 'content://example/item'],
},
{
name: 'positional component and unparsed trailing tokens',
args: ['-S', 'com.example/.Main', '--display', '0'],
intentArgs: ['com.example/.Main', '--display', '0'],
},
{
name: 'explicit end of options',
args: ['-S', '--', 'com.example/.Main', '-R', '2'],
intentArgs: ['--', 'com.example/.Main', '-R', '2'],
},
])('intent arguments preserve AOSP token boundaries for $name', ({ args, intentArgs }) => {
expect(buildAndroidIntentArgs('android.settings.SETTINGS', args)).toEqual({
launchArgs: [...ACTION_ARGS, ...args],
resolutionArgs: [...ACTION_ARGS, ...intentArgs],
});
});

test.each([
['--future-option', '-p', 'com.other'],
['--display=1'],
['-R'],
['--es', 'key'],
['-Sattached'],
['--display', 'not-a-number'],
['-R--display'],
['-d', 'content://example/item', '--selector', '-t', 'text/plain'],
['intent:#Intent;scheme=content;end'],
])('unprojectable launch arguments retain unknown ownership: %j', (...args) => {
expect(buildAndroidIntentArgs('android.settings.SETTINGS', args)).toEqual({
launchArgs: [...ACTION_ARGS, ...args],
resolutionArgs: undefined,
});
});

type ArgumentGroup = { role: 'intent' | 'launch'; tokens: string[] };

const flagLikeValueArb = fc.oneof(
fc.string({ maxLength: 40 }),
fc.constantFrom('-S', '-R', '--display', '--user', '--selector', '--', ''),
);
const integerLaunchFlagArb = fc.constantFrom(
'--sampling',
'--profiler-output-version',
'-R',
'--display',
'--task-display-area-feature-id',
'--windowingMode',
'--activityType',
'--task',
'--allow-fgs-start-reason',
);
const argumentGroupArb: fc.Arbitrary<ArgumentGroup> = fc.oneof(
fc
.tuple(fc.constantFrom('-a', '-d', '-t', '-i', '-c', '-p'), flagLikeValueArb)
.map<ArgumentGroup>((tokens) => ({ role: 'intent', tokens })),
fc
.tuple(fc.constantFrom('-e', '--es', '--esa', '--esal'), flagLikeValueArb, flagLikeValueArb)
.map<ArgumentGroup>((tokens) => ({ role: 'intent', tokens })),
flagLikeValueArb.map<ArgumentGroup>((key) => ({ role: 'intent', tokens: ['--esn', key] })),
fc
.constantFrom('-S', '-W', '-D', '--task-overlay')
.map<ArgumentGroup>((flag) => ({ role: 'launch', tokens: [flag] })),
fc
.tuple(fc.constantFrom('-P', '--attach-agent'), flagLikeValueArb)
.map<ArgumentGroup>((tokens) => ({ role: 'launch', tokens })),
fc
.tuple(
integerLaunchFlagArb,
fc.oneof(
fc.integer({ min: -2147483648, max: 2147483647 }).map(String),
fc.constantFrom('+0', '-0', '0001', '2147483647', '-2147483648', '٠١', '02'),
),
fc.boolean(),
)
.map<ArgumentGroup>(([flag, value, attached]) => ({
role: 'launch',
tokens: flag === '-R' && attached ? [flag + value] : [flag, value],
})),
);

test('intent projection preserves whole argument groups while launch preserves the original stream', () => {
fc.assert(
fc.property(fc.array(argumentGroupArb, { maxLength: 25 }), (groups) => {
const args = ['-t', 'application/test', ...groups.flatMap((group) => group.tokens)];
const intentArgs = [
'-t',
'application/test',
...groups.filter((group) => group.role === 'intent').flatMap((group) => group.tokens),
];
expect(buildAndroidIntentArgs('android.settings.SETTINGS', args)).toEqual({
launchArgs: [...ACTION_ARGS, ...args],
resolutionArgs: [...ACTION_ARGS, ...intentArgs],
});
}),
{ numRuns: PROPERTY_RUNS },
);
});

test('invalid native integer launch controls keep ownership unknown, including in selectors', () => {
fc.assert(
fc.property(
integerLaunchFlagArb,
fc.constantFrom('', '+', '--display', '1.0', '0x10', ' 1', '2147483648', '-2147483649', '𝟙'),
fc.array(argumentGroupArb, { maxLength: 10 }),
(flag, value, groups) => {
const args = [...groups.flatMap((group) => group.tokens), '--selector', flag, value];
expect(buildAndroidIntentArgs('android.settings.SETTINGS', args)).toEqual({
launchArgs: [...ACTION_ARGS, ...args],
resolutionArgs: undefined,
});
},
),
{ numRuns: PROPERTY_RUNS },
);
});
209 changes: 209 additions & 0 deletions packages/platform-android/src/intent-arguments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
type AndroidIntentOption = readonly [
audience: 'intent' | 'launch',
valueCount: 0 | 1 | 2,
valueType?: 'java-int',
];

/** AOSP Intent.parseCommandArgs and ActivityManagerShellCommand.makeIntent option arities. */
const ANDROID_INTENT_OPTIONS: Readonly<Record<string, AndroidIntentOption>> = {
'-a': ['intent', 1],
'-d': ['intent', 1],
'-t': ['intent', 1],
'-i': ['intent', 1],
'-c': ['intent', 1],
'-n': ['intent', 1],
'-p': ['intent', 1],
'-f': ['intent', 1],
'-e': ['intent', 2],
'--es': ['intent', 2],
'--esn': ['intent', 1],
'--ei': ['intent', 2],
'--eu': ['intent', 2],
'--ecn': ['intent', 2],
'--eia': ['intent', 2],
'--eial': ['intent', 2],
'--el': ['intent', 2],
'--ela': ['intent', 2],
'--elal': ['intent', 2],
'--ef': ['intent', 2],
'--efa': ['intent', 2],
'--efal': ['intent', 2],
'--ed': ['intent', 2],
'--eda': ['intent', 2],
'--edal': ['intent', 2],
'--esa': ['intent', 2],
'--esal': ['intent', 2],
'--ez': ['intent', 2],
'--grant-read-uri-permission': ['intent', 0],
'--grant-write-uri-permission': ['intent', 0],
'--grant-persistable-uri-permission': ['intent', 0],
'--grant-prefix-uri-permission': ['intent', 0],
'--exclude-stopped-packages': ['intent', 0],
'--include-stopped-packages': ['intent', 0],
'--debug-log-resolution': ['intent', 0],
'--activity-brought-to-front': ['intent', 0],
'--activity-clear-top': ['intent', 0],
'--activity-clear-when-task-reset': ['intent', 0],
'--activity-exclude-from-recents': ['intent', 0],
'--activity-launched-from-history': ['intent', 0],
'--activity-multiple-task': ['intent', 0],
'--activity-no-animation': ['intent', 0],
'--activity-no-history': ['intent', 0],
'--activity-no-user-action': ['intent', 0],
'--activity-previous-is-top': ['intent', 0],
'--activity-reorder-to-front': ['intent', 0],
'--activity-reset-task-if-needed': ['intent', 0],
'--activity-single-top': ['intent', 0],
'--activity-clear-task': ['intent', 0],
'--activity-task-on-home': ['intent', 0],
'--activity-match-external': ['intent', 0],
'--receiver-registered-only': ['intent', 0],
'--receiver-replace-pending': ['intent', 0],
'--receiver-foreground': ['intent', 0],
'--receiver-no-abort': ['intent', 0],
'--receiver-include-background': ['intent', 0],
'--selector': ['intent', 0],
'--user': ['intent', 1],
'-D': ['launch', 0],
'--suspend': ['launch', 0],
'-N': ['launch', 0],
'-W': ['launch', 0],
'-P': ['launch', 1],
'--start-profiler': ['launch', 1],
'--sampling': ['launch', 1, 'java-int'],
'--clock-type': ['launch', 1],
'--profiler-output-version': ['launch', 1, 'java-int'],
'--streaming': ['launch', 0],
'--attach-agent': ['launch', 1],
'--attach-agent-bind': ['launch', 1],
'-R': ['launch', 1, 'java-int'],
'-S': ['launch', 0],
'--track-allocation': ['launch', 0],
'--receiver-permission': ['launch', 1],
'--display': ['launch', 1, 'java-int'],
'--task-display-area-feature-id': ['launch', 1, 'java-int'],
'--windowingMode': ['launch', 1, 'java-int'],
'--activityType': ['launch', 1, 'java-int'],
'--task': ['launch', 1, 'java-int'],
'--task-overlay': ['launch', 0],
'--lock-task': ['launch', 0],
'--allow-background-activity-starts': ['launch', 0],
'--async': ['launch', 0],
'--splashscreen-show-icon': ['launch', 0],
'--dismiss-keyguard-if-insecure': ['launch', 0],
'--dismiss-keyguard': ['launch', 0],
'--allow-fgs-start-reason': ['launch', 1, 'java-int'],
};

type ParsedAndroidIntentOption = {
name: string;
audience: AndroidIntentOption[0];
end: number;
value: string | undefined;
};

/** Integer.parseInt accepts ASCII signs and BMP decimal digits, within signed 32-bit bounds. */
function isAndroidIntentJavaInteger(value: string): boolean {
if (!/^[+-]?\p{Decimal_Number}+$/u.test(value)) return false;
const negative = value[0] === '-';
const digits = value[0] === '-' || value[0] === '+' ? value.slice(1) : value;
let result = 0;
for (const digit of digits) {
if (digit.length !== 1) return false;
const code = digit.charCodeAt(0);
let zero = code;
while (/\p{Decimal_Number}/u.test(String.fromCharCode(zero - 1))) zero -= 1;
result = result * 10 + code - zero;
if (result > (negative ? 2147483648 : 2147483647)) return false;
}
return true;
}

function readAndroidIntentOption(
args: readonly string[],
index: number,
token: string,
): ParsedAndroidIntentOption | undefined {
const attachedValue = token.length > 2 && token[1] !== '-';
const name = attachedValue ? token.slice(0, 2) : token;
const option = ANDROID_INTENT_OPTIONS[name];
if (!option || (attachedValue && option[1] === 0)) return undefined;
const end = index + 1 + option[1] - Number(attachedValue);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (end > args.length) return undefined;
const value = attachedValue ? token.slice(2) : args[index + 1];
if (option[2] === 'java-int' && !isAndroidIntentJavaInteger(value ?? '')) return undefined;
return {
name,
audience: option[0],
end,
value,
};
}

function canResolvePositionalIntent(token: string | undefined, type: string | undefined): boolean {
return !token?.startsWith('intent:') && !(token?.startsWith('content:') && type === undefined);
}

function needsContentType(data: string | undefined, type: string | undefined): boolean {
// am start resolves content-provider MIME types; package lookup does not.
return data?.startsWith('content:') === true && type === undefined;
}

type AndroidIntentMime = { data?: string; type?: string };

function updateAndroidIntentMime(
mime: AndroidIntentMime,
name: string,
value: string | undefined,
): boolean {
if (name === '-d') mime.data = value;
if (name === '-t') mime.type = value;
return name !== '--selector' || !needsContentType(mime.data, mime.type);
Comment thread
thymikee marked this conversation as resolved.
}

function readAndroidIntentArguments(args: readonly string[]):
| {
options: Array<ParsedAndroidIntentOption & { tokens: string[] }>;
positional: string[];
}
| undefined {
const options: Array<ParsedAndroidIntentOption & { tokens: string[] }> = [];
let index = 0;
while (true) {
const token = args[index];
if (token === undefined) return { options, positional: [] };
if (!token.startsWith('-') || token === '--') return { options, positional: args.slice(index) };
const option = readAndroidIntentOption(args, index, token);
if (!option) return undefined;
options.push({ ...option, tokens: args.slice(index, option.end) });
index = option.end;
}
}

function androidIntentResolutionArgs(args: readonly string[]): string[] | undefined {
const parsed = readAndroidIntentArguments(args);
if (!parsed) return undefined;
const resolutionArgs: string[] = [];
const mime: AndroidIntentMime = {};
for (const option of parsed.options) {
if (!updateAndroidIntentMime(mime, option.name, option.value)) return undefined;
if (option.audience === 'intent') resolutionArgs.push(...option.tokens);
}
const positional = parsed.positional[parsed.positional[0] === '--' ? 1 : 0];
if (!canResolvePositionalIntent(positional, mime.type) || needsContentType(mime.data, mime.type))
return undefined;
resolutionArgs.push(...parsed.positional);
return resolutionArgs;
}

/** Launch stays verbatim; resolution receives only complete, recognized intent options. */
export function buildAndroidIntentArgs(
intent: string,
launchArgs: readonly string[] = [],
): { launchArgs: string[]; resolutionArgs: string[] | undefined } {
const resolutionArgs = androidIntentResolutionArgs(launchArgs);
return {
launchArgs: ['-a', intent, ...launchArgs],
resolutionArgs: resolutionArgs && ['-a', intent, ...resolutionArgs],
};
}
Loading
Loading