From c4cf0ff5304bf64b81cb7ea89209d11ffef151c9 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 18 Apr 2026 01:59:36 +0700 Subject: [PATCH] feat(driver): optional launcher_activity on Launch RPC for multi-alias apps --- cmd/uatu/main.go | 16 +++---- cmd/uatu/test_run.go | 2 +- internal/driver/driver.go | 6 ++- internal/driver/maestro/client.go | 8 +++- internal/driver/maestro/client_test.go | 6 ++- internal/driver/mock/mock.go | 19 +++++---- internal/driver/mock/mock_test.go | 2 +- proto/driverpb/driver.pb.go | 26 ++++++++---- proto/driverpb/driver.proto | 4 ++ .../kotlin/dev/uatu/sidecar/DriverBackend.kt | 42 ++++--------------- .../kotlin/dev/uatu/sidecar/DriverService.kt | 2 +- 11 files changed, 69 insertions(+), 64 deletions(-) diff --git a/cmd/uatu/main.go b/cmd/uatu/main.go index b0bb9dd..7282d60 100644 --- a/cmd/uatu/main.go +++ b/cmd/uatu/main.go @@ -11,13 +11,14 @@ import ( ) type testOptions struct { - spec string - bundleID string - platform string - avd string - duration time.Duration - seed int64 - output string + spec string + bundleID string + launcherActivity string + platform string + avd string + duration time.Duration + seed int64 + output string } const topUsage = `uatu is a property-based UI fuzzer for mobile apps. @@ -38,6 +39,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) { var options testOptions flagSet.StringVar(&options.spec, "spec", "", "path to the TypeScript spec (required)") flagSet.StringVar(&options.bundleID, "bundle-id", "", "target app bundle ID (required)") + flagSet.StringVar(&options.launcherActivity, "launcher-activity", "", "optional / to launch (overrides default resolution)") flagSet.StringVar(&options.platform, "platform", "android", "target platform: android (ios deferred)") flagSet.StringVar(&options.avd, "avd", "", "Android AVD name (required on android)") flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration") diff --git a/cmd/uatu/test_run.go b/cmd/uatu/test_run.go index 516c5e4..91f06a8 100644 --- a/cmd/uatu/test_run.go +++ b/cmd/uatu/test_run.go @@ -116,7 +116,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) acceptChannel <- acceptResult{connection: connection, err: acceptErr} }() - if err := driverClient.Launch(ctx, options.bundleID, false); err != nil { + if err := driverClient.Launch(ctx, options.bundleID, options.launcherActivity, false); err != nil { return fmt.Errorf("launch app: %w", err) } fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.bundleID, sdkAcceptTimeout.Seconds()) diff --git a/internal/driver/driver.go b/internal/driver/driver.go index b4d6b99..6ecb137 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -9,7 +9,11 @@ import ( // matches proto/driverpb/driver.proto. The Maestro sidecar implementation // lives under driver/maestro; tests use driver/mock. type Driver interface { - Launch(ctx context.Context, bundleID string, clearState bool) error + // Launch asks the backend to bring the target app to the foreground. + // launcherActivity is an optional "/" component that + // overrides the backend's default launcher resolution — needed for + // apps that declare multiple MAIN+LAUNCHER activities. + Launch(ctx context.Context, bundleID, launcherActivity string, clearState bool) error Terminate(ctx context.Context) error Tap(ctx context.Context, x, y int) error diff --git a/internal/driver/maestro/client.go b/internal/driver/maestro/client.go index 9b0dd33..7fc02b3 100644 --- a/internal/driver/maestro/client.go +++ b/internal/driver/maestro/client.go @@ -48,8 +48,12 @@ func (c *Client) WaitForHealth(ctx context.Context, pollInterval time.Duration) } } -func (c *Client) Launch(ctx context.Context, bundleID string, clearState bool) error { - _, err := c.stub.Launch(ctx, &driverpb.LaunchRequest{BundleId: bundleID, ClearState: clearState}) +func (c *Client) Launch(ctx context.Context, bundleID, launcherActivity string, clearState bool) error { + _, err := c.stub.Launch(ctx, &driverpb.LaunchRequest{ + BundleId: bundleID, + ClearState: clearState, + LauncherActivity: launcherActivity, + }) return err } diff --git a/internal/driver/maestro/client_test.go b/internal/driver/maestro/client_test.go index ab5a590..0e3b5a5 100644 --- a/internal/driver/maestro/client_test.go +++ b/internal/driver/maestro/client_test.go @@ -21,6 +21,7 @@ type fakeServer struct { healthCalls int launchedBundleID string + launcherActivity string clearState bool terminateCalls int taps []int32 @@ -49,6 +50,7 @@ func (s *fakeServer) Launch(_ context.Context, request *driverpb.LaunchRequest) s.mutex.Lock() defer s.mutex.Unlock() s.launchedBundleID = request.GetBundleId() + s.launcherActivity = request.GetLauncherActivity() s.clearState = request.GetClearState() return &driverpb.Empty{}, nil } @@ -188,10 +190,10 @@ func TestClient_LaunchAndTerminate(t *testing.T) { client, _ := Dial(state.address) defer client.Close() - if err := client.Launch(context.Background(), "com.example", true); err != nil { + if err := client.Launch(context.Background(), "com.example", "com.example/.MainActivity", true); err != nil { t.Fatal(err) } - if state.fake.launchedBundleID != "com.example" || !state.fake.clearState { + if state.fake.launchedBundleID != "com.example" || !state.fake.clearState || state.fake.launcherActivity != "com.example/.MainActivity" { t.Errorf("launch payload wrong: %+v", state.fake) } if err := client.Terminate(context.Background()); err != nil { diff --git a/internal/driver/mock/mock.go b/internal/driver/mock/mock.go index 2b7c755..6f94dcd 100644 --- a/internal/driver/mock/mock.go +++ b/internal/driver/mock/mock.go @@ -23,13 +23,14 @@ const ( ) type Action struct { - Kind ActionKind - BundleID string - ClearState bool - X, Y int - Selector string - Text string - Idle time.Duration + Kind ActionKind + BundleID string + LauncherActivity string + ClearState bool + X, Y int + Selector string + Text string + Idle time.Duration } // Driver is an in-memory Driver implementation for unit tests. @@ -76,11 +77,11 @@ func (d *Driver) failure(kind ActionKind) error { return d.Failures[kind] } -func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool) error { +func (d *Driver) Launch(ctx context.Context, bundleID, launcherActivity string, clearState bool) error { if err := d.failure(ActionLaunch); err != nil { return err } - d.record(Action{Kind: ActionLaunch, BundleID: bundleID, ClearState: clearState}) + d.record(Action{Kind: ActionLaunch, BundleID: bundleID, LauncherActivity: launcherActivity, ClearState: clearState}) return nil } diff --git a/internal/driver/mock/mock_test.go b/internal/driver/mock/mock_test.go index cd4f2ff..2bf94d1 100644 --- a/internal/driver/mock/mock_test.go +++ b/internal/driver/mock/mock_test.go @@ -29,7 +29,7 @@ func TestRecordsAllActionsInOrder(t *testing.T) { mock := New() ctx := context.Background() - if err := mock.Launch(ctx, "com.example", true); err != nil { + if err := mock.Launch(ctx, "com.example", "com.example/.MainActivity", true); err != nil { t.Fatal(err) } if err := mock.Tap(ctx, 100, 200); err != nil { diff --git a/proto/driverpb/driver.pb.go b/proto/driverpb/driver.pb.go index 9e80763..2e777a6 100644 --- a/proto/driverpb/driver.pb.go +++ b/proto/driverpb/driver.pb.go @@ -58,11 +58,15 @@ func (*Empty) Descriptor() ([]byte, []int) { } type LaunchRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - BundleId string `protobuf:"bytes,1,opt,name=bundle_id,json=bundleId,proto3" json:"bundle_id,omitempty"` - ClearState bool `protobuf:"varint,2,opt,name=clear_state,json=clearState,proto3" json:"clear_state,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + BundleId string `protobuf:"bytes,1,opt,name=bundle_id,json=bundleId,proto3" json:"bundle_id,omitempty"` + ClearState bool `protobuf:"varint,2,opt,name=clear_state,json=clearState,proto3" json:"clear_state,omitempty"` + // Optional "/" component to pass to `am start -n`. Needed + // when an app declares multiple MAIN+LAUNCHER activities where the first + // one reported by `pm dump` is an alias whose target class is disabled. + LauncherActivity string `protobuf:"bytes,3,opt,name=launcher_activity,json=launcherActivity,proto3" json:"launcher_activity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LaunchRequest) Reset() { @@ -109,6 +113,13 @@ func (x *LaunchRequest) GetClearState() bool { return false } +func (x *LaunchRequest) GetLauncherActivity() string { + if x != nil { + return x.LauncherActivity + } + return "" +} + type Point struct { state protoimpl.MessageState `protogen:"open.v1"` X int32 `protobuf:"varint,1,opt,name=x,proto3" json:"x,omitempty"` @@ -462,11 +473,12 @@ var File_driverpb_driver_proto protoreflect.FileDescriptor const file_driverpb_driver_proto_rawDesc = "" + "\n" + "\x15driverpb/driver.proto\x12\x0euatu.driver.v1\"\a\n" + - "\x05Empty\"M\n" + + "\x05Empty\"z\n" + "\rLaunchRequest\x12\x1b\n" + "\tbundle_id\x18\x01 \x01(\tR\bbundleId\x12\x1f\n" + "\vclear_state\x18\x02 \x01(\bR\n" + - "clearState\"#\n" + + "clearState\x12+\n" + + "\x11launcher_activity\x18\x03 \x01(\tR\x10launcherActivity\"#\n" + "\x05Point\x12\f\n" + "\x01x\x18\x01 \x01(\x05R\x01x\x12\f\n" + "\x01y\x18\x02 \x01(\x05R\x01y\"\x1c\n" + diff --git a/proto/driverpb/driver.proto b/proto/driverpb/driver.proto index 69757c6..2850d03 100644 --- a/proto/driverpb/driver.proto +++ b/proto/driverpb/driver.proto @@ -26,6 +26,10 @@ message Empty {} message LaunchRequest { string bundle_id = 1; bool clear_state = 2; + // Optional "/" component to pass to `am start -n`. Needed + // when an app declares multiple MAIN+LAUNCHER activities where the first + // one reported by `pm dump` is an alias whose target class is disabled. + string launcher_activity = 3; } message Point { diff --git a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt index 35930fe..b2f44ec 100644 --- a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt @@ -1,7 +1,7 @@ package dev.uatu.sidecar interface DriverBackend { - fun launch(bundleId: String, clearState: Boolean) + fun launch(bundleId: String, launcherActivity: String, clearState: Boolean) fun terminate(bundleId: String) fun tap(x: Int, y: Int) fun tapSelector(selector: String) @@ -30,47 +30,22 @@ class StubDriverBackend(private val platform: String) : DriverBackend { @Volatile var lastInputText: String? = null private set - override fun launch(bundleId: String, clearState: Boolean) { + override fun launch(bundleId: String, launcherActivity: String, clearState: Boolean) { launchCount++ lastBundleId = bundleId if (clearState) { runAdb(listOf("shell", "pm", "clear", bundleId)) } - val launcherComponent = resolveLauncherActivity(bundleId) - if (launcherComponent != null) { - runAdb(listOf("shell", "am", "start", "-n", launcherComponent)) + if (launcherActivity.isNotEmpty()) { + val component = if (launcherActivity.contains('/')) launcherActivity else "$bundleId/$launcherActivity" + runAdb(listOf("shell", "am", "start", "-n", component)) } else { - // Fallback to monkey (less reliable on emulators without physical keys). + // `monkey` uses PackageManager.getLaunchIntentForPackage, which + // picks the canonical default launcher. runAdb(listOf("shell", "monkey", "-p", bundleId, "-c", "android.intent.category.LAUNCHER", "1")) } } - private fun resolveLauncherActivity(bundleId: String): String? { - return try { - val process = ProcessBuilder(listOf("adb", "shell", "pm", "dump", bundleId)) - .redirectErrorStream(true).start() - process.waitFor() - val output = process.inputStream.bufferedReader().readText() - // pm dump emits stanzas like: - // / filter - // Action: "android.intent.action.MAIN" - // Category: "android.intent.category.LAUNCHER" - // Pick the first / followed by both MAIN + LAUNCHER. - val componentPattern = Regex("\\s+\\S+\\s+($bundleId/[\\w.\$]+)\\s+filter\\b") - val lines = output.lines() - for (index in lines.indices) { - val match = componentPattern.find(lines[index]) ?: continue - val window = lines.subList(index, minOf(index + 12, lines.size)).joinToString("\n") - if (window.contains("android.intent.action.MAIN") && window.contains("android.intent.category.LAUNCHER")) { - return match.groupValues[1] - } - } - null - } catch (_: Exception) { - null - } - } - override fun terminate(bundleId: String) { runAdb(listOf("shell", "am", "force-stop", bundleId)) lastBundleId = null @@ -95,9 +70,10 @@ class StubDriverBackend(private val platform: String) : DriverBackend { private fun runAdb(arguments: List) { try { val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start() + // Drain output before waiting so a large write doesn't block the child. + command.inputStream.bufferedReader().readText() command.waitFor() } catch (cause: Exception) { - // Best-effort: log and continue. println("adb ${arguments.joinToString(" ")} failed: $cause") } } diff --git a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverService.kt b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverService.kt index c4187b0..4ad5cd2 100644 --- a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverService.kt +++ b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverService.kt @@ -24,7 +24,7 @@ class DriverService( override fun launch(request: LaunchRequest, responseObserver: StreamObserver) { runRpc(responseObserver) { - backend.launch(request.bundleId, request.clearState) + backend.launch(request.bundleId, request.launcherActivity, request.clearState) launchedBundleId.set(request.bundleId) Empty.getDefaultInstance() }