feat(permissions): grant dangerous permissions via aapt + adb

Inspector parses uses-permission entries from `aapt dump permissions`
and the granter shells out to `adb shell pm grant`. Both are pluggable
so tests can drive logic without aapt or a device. Granter failures
become warnings rather than errors — Android refuses non-runtime
permissions and we'd rather keep going than abort the run.
This commit is contained in:
pj committed 2026-04-17 23:57:17 +07:00
1 parent b6b18c3681
commit d856c40d7c
2 files changed
+252

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
package permissions
import (
"context"
"fmt"
"os/exec"
"regexp"
"strings"
)
// Inspector returns the list of uses-permission entries declared by the APK.
type Inspector func(ctx context.Context, apkPath string) ([]string, error)
// Granter grants a single permission to the given package on the connected
// device. Implementations typically wrap `adb shell pm grant`.
type Granter func(ctx context.Context, packageName, permission string) error
// GrantDangerous filters declared permissions to the dangerous set and grants
// each one via the supplied granter. Errors from individual grants are
// collected as warnings rather than aborting — Android refuses to grant
// non-runtime permissions and we prefer to soldier on.
func GrantDangerous(
ctx context.Context,
apkPath, packageName string,
inspector Inspector,
granter Granter,
) (granted []string, warnings []string, err error) {
declared, err := inspector(ctx, apkPath)
if err != nil {
return nil, nil, fmt.Errorf("inspect permissions: %w", err)
}
for _, permission := range declared {
if !IsDangerous(permission) {
continue
}
if err := granter(ctx, packageName, permission); err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", permission, err))
continue
}
granted = append(granted, permission)
}
return granted, warnings, nil
}
var aaptPermissionPattern = regexp.MustCompile(`uses-permission:\s+name='([^']+)'`)
// AaptInspector shells out to `aapt dump permissions` to list permissions.
func AaptInspector(ctx context.Context, apkPath string) ([]string, error) {
output, err := exec.CommandContext(ctx, "aapt", "dump", "permissions", apkPath).Output()
if err != nil {
return nil, fmt.Errorf("aapt dump permissions: %w", err)
}
var permissions []string
for _, match := range aaptPermissionPattern.FindAllStringSubmatch(string(output), -1) {
permissions = append(permissions, match[1])
}
return permissions, nil
}
// AdbGranter returns a Granter that runs `adb shell pm grant` against the
// supplied device serial (empty = default device).
func AdbGranter(adbPath, deviceSerial string) Granter {
return func(ctx context.Context, packageName, permission string) error {
arguments := []string{"shell", "pm", "grant", packageName, permission}
if deviceSerial != "" {
arguments = append([]string{"-s", deviceSerial}, arguments...)
}
command := exec.CommandContext(ctx, adbPath, arguments...)
output, err := command.CombinedOutput()
if err != nil {
return fmt.Errorf("adb pm grant %s: %w (%s)", permission, err, strings.TrimSpace(string(output)))
}
return nil
}
}
// dangerousPermissions captures Android's PROTECTION_DANGEROUS set as of
// API 34. New runtime permissions added in later releases should be appended
// here when needed.
var dangerousPermissions = map[string]bool{
"android.permission.READ_CALENDAR": true,
"android.permission.WRITE_CALENDAR": true,
"android.permission.CAMERA": true,
"android.permission.READ_CONTACTS": true,
"android.permission.WRITE_CONTACTS": true,
"android.permission.GET_ACCOUNTS": true,
"android.permission.ACCESS_FINE_LOCATION": true,
"android.permission.ACCESS_COARSE_LOCATION": true,
"android.permission.ACCESS_BACKGROUND_LOCATION": true,
"android.permission.RECORD_AUDIO": true,
"android.permission.READ_PHONE_STATE": true,
"android.permission.READ_PHONE_NUMBERS": true,
"android.permission.CALL_PHONE": true,
"android.permission.ANSWER_PHONE_CALLS": true,
"android.permission.READ_CALL_LOG": true,
"android.permission.WRITE_CALL_LOG": true,
"android.permission.ADD_VOICEMAIL": true,
"android.permission.USE_SIP": true,
"android.permission.PROCESS_OUTGOING_CALLS": true,
"android.permission.BODY_SENSORS": true,
"android.permission.SEND_SMS": true,
"android.permission.RECEIVE_SMS": true,
"android.permission.READ_SMS": true,
"android.permission.RECEIVE_WAP_PUSH": true,
"android.permission.RECEIVE_MMS": true,
"android.permission.READ_EXTERNAL_STORAGE": true,
"android.permission.WRITE_EXTERNAL_STORAGE": true,
"android.permission.ACCESS_MEDIA_LOCATION": true,
"android.permission.ACTIVITY_RECOGNITION": true,
"android.permission.POST_NOTIFICATIONS": true,
"android.permission.NEARBY_WIFI_DEVICES": true,
"android.permission.READ_MEDIA_IMAGES": true,
"android.permission.READ_MEDIA_VIDEO": true,
"android.permission.READ_MEDIA_AUDIO": true,
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED": true,
"android.permission.BLUETOOTH_CONNECT": true,
"android.permission.BLUETOOTH_ADVERTISE": true,
"android.permission.BLUETOOTH_SCAN": true,
"android.permission.UWB_RANGING": true,
"android.permission.BODY_SENSORS_BACKGROUND": true,
}
func IsDangerous(permission string) bool {
return dangerousPermissions[permission]
}
+127
View File
@@ -0,0 +1,127 @@
package permissions
import (
"context"
"errors"
"slices"
"testing"
)
func TestGrantDangerous_FiltersAndCallsGranter(t *testing.T) {
declared := []string{
"android.permission.INTERNET", // not dangerous
"android.permission.CAMERA", // dangerous
"android.permission.WAKE_LOCK", // not dangerous
"android.permission.ACCESS_FINE_LOCATION", // dangerous
"android.permission.READ_EXTERNAL_STORAGE", // dangerous
"android.permission.SYSTEM_ALERT_WINDOW", // not dangerous
}
inspector := func(_ context.Context, _ string) ([]string, error) { return declared, nil }
var requested []string
granter := func(_ context.Context, packageName, permission string) error {
if packageName != "com.example" {
t.Errorf("granter received wrong package: %q", packageName)
}
requested = append(requested, permission)
return nil
}
granted, warnings, err := GrantDangerous(context.Background(), "/path/to/apk", "com.example", inspector, granter)
if err != nil {
t.Fatal(err)
}
want := []string{
"android.permission.CAMERA",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.READ_EXTERNAL_STORAGE",
}
if !slices.Equal(granted, want) {
t.Errorf("granted permissions: got %v, want %v", granted, want)
}
if !slices.Equal(requested, want) {
t.Errorf("granter calls: got %v, want %v", requested, want)
}
if len(warnings) != 0 {
t.Errorf("expected no warnings, got %v", warnings)
}
}
func TestGrantDangerous_CollectsGranterFailuresAsWarnings(t *testing.T) {
declared := []string{"android.permission.CAMERA", "android.permission.RECORD_AUDIO"}
inspector := func(_ context.Context, _ string) ([]string, error) { return declared, nil }
granter := func(_ context.Context, _, permission string) error {
if permission == "android.permission.CAMERA" {
return errors.New("device denied")
}
return nil
}
granted, warnings, err := GrantDangerous(context.Background(), "/x", "com.example", inspector, granter)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(granted, []string{"android.permission.RECORD_AUDIO"}) {
t.Errorf("granted: %v", granted)
}
if len(warnings) != 1 || warnings[0] != "android.permission.CAMERA: device denied" {
t.Errorf("warnings: %v", warnings)
}
}
func TestGrantDangerous_InspectorErrorBubbles(t *testing.T) {
inspector := func(_ context.Context, _ string) ([]string, error) {
return nil, errors.New("aapt missing")
}
_, _, err := GrantDangerous(context.Background(), "/x", "com.example", inspector, nil)
if err == nil || err.Error() == "" {
t.Errorf("expected wrapped inspector error, got %v", err)
}
}
func TestIsDangerous_KnownPermissions(t *testing.T) {
dangerous := []string{
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO",
"android.permission.POST_NOTIFICATIONS",
}
for _, permission := range dangerous {
if !IsDangerous(permission) {
t.Errorf("expected %q to be dangerous", permission)
}
}
normal := []string{
"android.permission.INTERNET",
"android.permission.WAKE_LOCK",
"android.permission.SYSTEM_ALERT_WINDOW",
}
for _, permission := range normal {
if IsDangerous(permission) {
t.Errorf("expected %q not to be dangerous", permission)
}
}
}
func TestAaptInspector_ParsesFixtureOutput(t *testing.T) {
// Direct test of the regex; avoids spawning aapt.
output := `
package: com.example
sdkVersion:'24'
uses-permission: name='android.permission.INTERNET'
uses-permission: name='android.permission.CAMERA'
uses-permission: name='android.permission.WAKE_LOCK'
`
matches := aaptPermissionPattern.FindAllStringSubmatch(output, -1)
got := make([]string, 0, len(matches))
for _, match := range matches {
got = append(got, match[1])
}
want := []string{
"android.permission.INTERNET",
"android.permission.CAMERA",
"android.permission.WAKE_LOCK",
}
if !slices.Equal(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}