diff --git a/internal/playscan/manifest.go b/internal/playscan/manifest.go index 2b4ec09..741bd95 100644 --- a/internal/playscan/manifest.go +++ b/internal/playscan/manifest.go @@ -63,13 +63,29 @@ const ( // featureFormFactors maps the declaration that puts an app on a // non-phone Play track to that form factor. +// +// Every name here was read back from PackageManager's constant pool in +// android.jar on android-34, -35 and -36, which all agree. var featureFormFactors = map[string]FormFactor{ - "android.hardware.type.watch": FormFactorWear, - "android.hardware.type.television": FormFactorTV, - "android.software.leanback": FormFactorTV, - "android.hardware.type.automotive": FormFactorAutomotive, - "android.software.xr.immersive": FormFactorXR, - "android.hardware.xr.head_tracking": FormFactorXR, + "android.hardware.type.watch": FormFactorWear, + "android.hardware.type.television": FormFactorTV, + "android.software.leanback": FormFactorTV, + "android.hardware.type.automotive": FormFactorAutomotive, +} + +// xrFeaturePrefixes cover Android XR, which is matched by namespace rather than +// by exact name. +// +// No XR feature constant appears in any android.jar shipped for API 34 to 36, +// so an exact name could not be verified the way the ones above were. Guessing +// one is how this rule broke before: two invented names meant no XR app ever +// matched, and XR packages kept getting the phone track's blocking finding. +// Matching the namespace is correct for every current XR feature +// (android.software.xr.api.spatial, android.software.xr.api.openxr, the +// android.hardware.xr.* inputs) and for ones added later. +var xrFeaturePrefixes = []string{ + "android.software.xr.", + "android.hardware.xr.", } // FormFactor reports the non-phone form factor this manifest declares, or @@ -86,6 +102,11 @@ func (m *Manifest) FormFactor() FormFactor { if ff, ok := featureFormFactors[f.Name]; ok { return ff } + for _, prefix := range xrFeaturePrefixes { + if strings.HasPrefix(f.Name, prefix) { + return FormFactorXR + } + } } return FormFactorPhone } diff --git a/internal/playscan/rules_test.go b/internal/playscan/rules_test.go index 07c60c8..e12aa01 100644 --- a/internal/playscan/rules_test.go +++ b/internal/playscan/rules_test.go @@ -15,7 +15,9 @@ func TestTargetAPILevelRespectsFormFactor(t *testing.T) { {"wear", "android.hardware.type.watch", FormFactorWear}, {"tv", "android.software.leanback", FormFactorTV}, {"automotive", "android.hardware.type.automotive", FormFactorAutomotive}, - {"xr", "android.software.xr.immersive", FormFactorXR}, + {"xr spatial", "android.software.xr.api.spatial", FormFactorXR}, + {"xr openxr", "android.software.xr.api.openxr", FormFactorXR}, + {"xr hardware input", "android.hardware.xr.input.controller", FormFactorXR}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -71,3 +73,45 @@ func TestTargetAPILevelFormFactorCleanWhenCurrent(t *testing.T) { t.Errorf("want no findings, got %d: %s", len(findings), findings[0].Title) } } + +// XR is matched by namespace, not by an exact constant name. The rule shipped +// with two invented names (android.software.xr.immersive, +// android.hardware.xr.head_tracking) that no real XR app declares, so no XR +// package ever matched and they kept getting the phone track's blocking +// finding. No XR constant appears in android.jar for API 34-36, so the +// namespace is what can actually be relied on. +func TestFormFactorMatchesXRByNamespace(t *testing.T) { + xr := []string{ + "android.software.xr.api.spatial", + "android.software.xr.api.openxr", + "android.hardware.xr.input.controller", + "android.software.xr.something.added.later", + } + for _, name := range xr { + m := &Manifest{UsesFeatures: []UsesFeature{{Name: name}}} + if got := m.FormFactor(); got != FormFactorXR { + t.Errorf("%s: FormFactor = %q, want %q", name, got, FormFactorXR) + } + } + + // The namespace must not swallow unrelated features that merely contain + // "xr", nor an XR feature the app says it does not require. + notXR := []string{ + "android.hardware.camera", + "android.software.xrated", // not the xr namespace: no dot after xr + "com.example.xr.custom", + } + for _, name := range notXR { + m := &Manifest{UsesFeatures: []UsesFeature{{Name: name}}} + if got := m.FormFactor(); got != FormFactorPhone { + t.Errorf("%s: FormFactor = %q, want phone", name, got) + } + } + + optional := &Manifest{UsesFeatures: []UsesFeature{ + {Name: "android.software.xr.api.spatial", Required: "false"}, + }} + if got := optional.FormFactor(); got != FormFactorPhone { + t.Errorf("optional XR feature: FormFactor = %q, want phone", got) + } +}