fix(android): ignore action-less filters in activity export defaults (#8030)

Android's package parser discards an activity, activity-alias or receiver
intent filter that declares no action, so such a filter neither makes the
component exported by default nor triggers the API 31 explicit-export
requirement. Services keep action-less filters. The manifest inventory
counted every declared filter, reporting exported_default true (or an
explicit-export validation error) for components Android does not export.
intent_filter_count still reports every declared filter.
This commit is contained in:
Dragolone authored and GitHub committed 2026-11-04 19:28:59 +08:00
1 parent 810cebcba0
commit d857b61bc5
4 files changed
+85 -3

No files matched your search

+3
View File
@@ -73,6 +73,9 @@ spellings; the original SDK declaration strings remain unchanged.
it remains null for unknown provider target SDKs, nonliteral declarations, or
components with filters targeting API 31 or later that omit the now-required
explicit declaration. A limitation records the latter validation error.
As in Android's package parser, an activity, alias or receiver filter without
an `<action>` is discarded and does not count toward either rule; a service
keeps such a filter. `intent_filter_count` still counts every declared filter.
Provider authorities and all inventory dimensions are bounded. `status:
"partial"` and `limitations` identify omitted declarations or values; a
capacity limitation appears only when an actual collection limit was reached.
@@ -32,6 +32,20 @@ const projectedDataAttributes = new Set([
const literalBoolean = (value: string | null): boolean | null =>
value === "true" ? true : value === "false" ? false : null;
/** Whether a filter names an action, which Android requires to keep an activity filter. */
const declaresAction = (filter: Element): boolean => {
for (let index = 0; index < filter.childNodes.length; index += 1) {
const member = filter.childNodes.item(index);
if (
isManifestElement(member) &&
member.nodeName === "action" &&
androidAttribute(member, "name") !== null
)
return true;
}
return false;
};
/** One decoded-manifest lifetime owns nested declarations and cumulative copy budgets. */
export const createAndroidManifestDetailsReader = (
source: string,
@@ -99,6 +113,7 @@ export const createAndroidManifestDetailsReader = (
let legacyValuesRemaining = limits.intentValues;
let legacyTruncated = false;
let filterCount = 0;
let actionFilter = false;
for (let index = 0; index < component.childNodes.length; index += 1) {
const child = component.childNodes.item(index);
if (!isManifestElement(child)) continue;
@@ -111,6 +126,8 @@ export const createAndroidManifestDetailsReader = (
continue;
}
if (child.nodeName !== "intent-filter") continue;
// Checked past the filter bound too; one action-bearing filter suffices.
actionFilter ||= declaresAction(child);
const filterIndex = filterCount++;
if (filterIndex >= limits.intentFilters) {
partial = true;
@@ -237,6 +254,7 @@ export const createAndroidManifestDetailsReader = (
actions,
categories,
filterCount,
actionFilter,
legacyTruncated,
partial,
detailLimited,
+10 -3
View File
@@ -454,11 +454,18 @@ export const parseAndroidManifestComponents = (
: null;
const exportedNonliteral =
declaredExported !== null && exportedLiteral === null;
// Android discards an activity, alias or receiver filter that declares
// no action, while a service keeps it. Only kept filters select the
// export default and the API 31 explicit-export requirement.
const keptFilters =
component.nodeName === "service"
? details.filterCount > 0
: details.actionFilter;
const explicitExportRequired =
declaredExported === null &&
targetSdkLevel !== null &&
targetSdkLevel >= 31 &&
details.filterCount > 0 &&
keptFilters &&
(component.nodeName === "activity" ||
component.nodeName === "activity-alias" ||
component.nodeName === "service" ||
@@ -466,7 +473,7 @@ export const parseAndroidManifestComponents = (
const filteredExportUnknown =
declaredExported === null &&
targetSdkLevel === null &&
details.filterCount > 0 &&
keptFilters &&
(component.nodeName === "activity" ||
component.nodeName === "activity-alias" ||
component.nodeName === "service" ||
@@ -491,7 +498,7 @@ export const parseAndroidManifestComponents = (
? null
: component.nodeName === "provider"
? providerDefault
: details.filterCount > 0,
: keptFilters,
permission,
permission_source: effectivePermissionSource,
intent_filter_count: details.filterCount,
@@ -689,6 +689,60 @@ it("leaves target-31-or-later filtered components without an inferred export def
).toMatchObject({ exported_default: null });
});
it("ignores action-less activity, alias and receiver filters for export defaults", () => {
// Android's package parser drops these filters; a service keeps them.
const manifest = [
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application>',
'<activity android:name=".Main"><intent-filter><category android:name="android.intent.category.DEFAULT"/></intent-filter></activity>',
'<activity-alias android:name=".Alias" android:targetActivity=".Main"><intent-filter><data android:scheme="https"/></intent-filter></activity-alias>',
'<receiver android:name=".Receiver"><intent-filter android:priority="1"/></receiver>',
'<service android:name=".Service"><intent-filter><category android:name="example.BIND"/></intent-filter></service>',
'<activity android:name=".Mixed"><intent-filter/><intent-filter><action android:name="example.OPEN"/></intent-filter></activity>',
"</application></manifest>",
].join("");
const defaults = (sdk: string) =>
parseAndroidManifestComponents(manifest, "complete", sdk).items.map(
(item) => [item.intent_filter_count, item.exported_default],
);
expect(defaults("30")).toEqual([
[1, false],
[1, false],
[1, false],
[1, true],
[2, true],
]);
const modern = parseAndroidManifestComponents(manifest, "complete", "31");
expect(modern.items.map((item) => item.exported_default)).toEqual([
false,
false,
false,
null,
null,
]);
expect(modern.limitations.join(" ")).toContain(
"2 component(s) with intent filters target API 31",
);
expect(defaults("Preview").map(([, value]) => value)).toEqual([
false,
false,
false,
null,
null,
]);
const filters = "<intent-filter/>".repeat(
ANDROID_MANIFEST_LIMITS.intentFilters,
);
const late = parseAndroidManifestComponents(
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name=".Late">${filters}<intent-filter><action android:name="example.OPEN"/></intent-filter></activity></application></manifest>`,
"complete",
"30",
);
expect(late.items[0]).toMatchObject({
intent_filter_count: ANDROID_MANIFEST_LIMITS.intentFilters + 1,
exported_default: true,
});
});
it("ignores same-local-name attributes from other namespaces", () => {
const manifest = [
'<?xml version="1.0" encoding="utf-8"?>',