diff --git a/internal/android/android.go b/internal/android/android.go index cc47fae..7af8838 100644 --- a/internal/android/android.go +++ b/internal/android/android.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "slices" "strings" "time" @@ -236,6 +237,38 @@ func waitForBoot(ctx context.Context, timeout time.Duration) error { } } +// ForegroundPackage returns the package of the currently resumed activity on +// the connected device, or "" when it cannot be determined. +func ForegroundPackage(ctx context.Context) (string, error) { + adb, err := AdbBinary() + if err != nil { + return "", err + } + output, err := exec.CommandContext(ctx, adb, "shell", "dumpsys", "activity", "activities").Output() + if err != nil { + return "", err + } + return parseForegroundPackage(string(output)), nil +} + +// resumedActivityPackage matches the "/" component name that +// dumpsys prints on ResumedActivity lines, capturing the package. +var resumedActivityPackage = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9_.]*)/[a-zA-Z0-9_.$]+`) + +// parseForegroundPackage extracts the foreground package from `dumpsys activity +// activities` output by reading the first ResumedActivity component name. +func parseForegroundPackage(dumpsys string) string { + for line := range strings.SplitSeq(dumpsys, "\n") { + if !strings.Contains(line, "ResumedActivity") { + continue + } + if match := resumedActivityPackage.FindStringSubmatch(line); match != nil { + return match[1] + } + } + return "" +} + func bootCompleted(ctx context.Context) (bool, error) { adb, err := AdbBinary() if err != nil { diff --git a/internal/android/android_test.go b/internal/android/android_test.go index c551667..4bf3747 100644 --- a/internal/android/android_test.go +++ b/internal/android/android_test.go @@ -91,3 +91,39 @@ func TestPathContains(t *testing.T) { t.Error("did not expect /nope in PATH") } } + +func TestParseForegroundPackage(t *testing.T) { + cases := []struct { + name string + dumpsys string + want string + }{ + { + name: "mResumedActivity folio", + dumpsys: " Stack #0:\n mResumedActivity: ActivityRecord{a1b2c3 u0 app.folio/.MainActivity t42}\n", + want: "app.folio", + }, + { + name: "topResumedActivity chrome", + dumpsys: "ResumedActivity: ActivityRecord{ff u0 com.android.chrome/com.google.android.apps.chrome.Main t9}\n topResumedActivity=ActivityRecord{ff u0 com.android.chrome/com.google.android.apps.chrome.Main}", + want: "com.android.chrome", + }, + { + name: "launcher", + dumpsys: " mResumedActivity: ActivityRecord{x u0 com.google.android.apps.nexuslauncher/.NexusLauncherActivity t1}", + want: "com.google.android.apps.nexuslauncher", + }, + { + name: "no resumed activity", + dumpsys: " some unrelated dumpsys output\n with no resumed line\n", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := parseForegroundPackage(tc.dumpsys); got != tc.want { + t.Errorf("parseForegroundPackage = %q, want %q", got, tc.want) + } + }) + } +}