feat(driver): optional launcher_activity on Launch RPC for multi-alias apps

This commit is contained in:
pj committed 2026-04-18 01:59:36 +07:00
1 parent 6d81e24e71
commit c4cf0ff530
11 files changed
+50 -45

No files matched your search

+2
View File
@@ -13,6 +13,7 @@ import (
type testOptions struct { type testOptions struct {
spec string spec string
bundleID string bundleID string
launcherActivity string
platform string platform string
avd string avd string
duration time.Duration duration time.Duration
@@ -38,6 +39,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
var options testOptions var options testOptions
flagSet.StringVar(&options.spec, "spec", "", "path to the TypeScript spec (required)") 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.bundleID, "bundle-id", "", "target app bundle ID (required)")
flagSet.StringVar(&options.launcherActivity, "launcher-activity", "", "optional <pkg>/<activity> to launch (overrides default resolution)")
flagSet.StringVar(&options.platform, "platform", "android", "target platform: android (ios deferred)") flagSet.StringVar(&options.platform, "platform", "android", "target platform: android (ios deferred)")
flagSet.StringVar(&options.avd, "avd", "", "Android AVD name (required on android)") flagSet.StringVar(&options.avd, "avd", "", "Android AVD name (required on android)")
flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration") flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration")
+1 -1
View File
@@ -116,7 +116,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
acceptChannel <- acceptResult{connection: connection, err: acceptErr} 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) return fmt.Errorf("launch app: %w", err)
} }
fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.bundleID, sdkAcceptTimeout.Seconds()) fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.bundleID, sdkAcceptTimeout.Seconds())
+5 -1
View File
@@ -9,7 +9,11 @@ import (
// matches proto/driverpb/driver.proto. The Maestro sidecar implementation // matches proto/driverpb/driver.proto. The Maestro sidecar implementation
// lives under driver/maestro; tests use driver/mock. // lives under driver/maestro; tests use driver/mock.
type Driver interface { 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 "<pkg>/<activity>" 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 Terminate(ctx context.Context) error
Tap(ctx context.Context, x, y int) error Tap(ctx context.Context, x, y int) error
+6 -2
View File
@@ -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 { func (c *Client) Launch(ctx context.Context, bundleID, launcherActivity string, clearState bool) error {
_, err := c.stub.Launch(ctx, &driverpb.LaunchRequest{BundleId: bundleID, ClearState: clearState}) _, err := c.stub.Launch(ctx, &driverpb.LaunchRequest{
BundleId: bundleID,
ClearState: clearState,
LauncherActivity: launcherActivity,
})
return err return err
} }
+4 -2
View File
@@ -21,6 +21,7 @@ type fakeServer struct {
healthCalls int healthCalls int
launchedBundleID string launchedBundleID string
launcherActivity string
clearState bool clearState bool
terminateCalls int terminateCalls int
taps []int32 taps []int32
@@ -49,6 +50,7 @@ func (s *fakeServer) Launch(_ context.Context, request *driverpb.LaunchRequest)
s.mutex.Lock() s.mutex.Lock()
defer s.mutex.Unlock() defer s.mutex.Unlock()
s.launchedBundleID = request.GetBundleId() s.launchedBundleID = request.GetBundleId()
s.launcherActivity = request.GetLauncherActivity()
s.clearState = request.GetClearState() s.clearState = request.GetClearState()
return &driverpb.Empty{}, nil return &driverpb.Empty{}, nil
} }
@@ -188,10 +190,10 @@ func TestClient_LaunchAndTerminate(t *testing.T) {
client, _ := Dial(state.address) client, _ := Dial(state.address)
defer client.Close() 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) 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) t.Errorf("launch payload wrong: %+v", state.fake)
} }
if err := client.Terminate(context.Background()); err != nil { if err := client.Terminate(context.Background()); err != nil {
+3 -2
View File
@@ -25,6 +25,7 @@ const (
type Action struct { type Action struct {
Kind ActionKind Kind ActionKind
BundleID string BundleID string
LauncherActivity string
ClearState bool ClearState bool
X, Y int X, Y int
Selector string Selector string
@@ -76,11 +77,11 @@ func (d *Driver) failure(kind ActionKind) error {
return d.Failures[kind] 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 { if err := d.failure(ActionLaunch); err != nil {
return err return err
} }
d.record(Action{Kind: ActionLaunch, BundleID: bundleID, ClearState: clearState}) d.record(Action{Kind: ActionLaunch, BundleID: bundleID, LauncherActivity: launcherActivity, ClearState: clearState})
return nil return nil
} }
+1 -1
View File
@@ -29,7 +29,7 @@ func TestRecordsAllActionsInOrder(t *testing.T) {
mock := New() mock := New()
ctx := context.Background() 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) t.Fatal(err)
} }
if err := mock.Tap(ctx, 100, 200); err != nil { if err := mock.Tap(ctx, 100, 200); err != nil {
+14 -2
View File
@@ -61,6 +61,10 @@ type LaunchRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
BundleId string `protobuf:"bytes,1,opt,name=bundle_id,json=bundleId,proto3" json:"bundle_id,omitempty"` 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"` ClearState bool `protobuf:"varint,2,opt,name=clear_state,json=clearState,proto3" json:"clear_state,omitempty"`
// Optional "<pkg>/<activity>" 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -109,6 +113,13 @@ func (x *LaunchRequest) GetClearState() bool {
return false return false
} }
func (x *LaunchRequest) GetLauncherActivity() string {
if x != nil {
return x.LauncherActivity
}
return ""
}
type Point struct { type Point struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
X int32 `protobuf:"varint,1,opt,name=x,proto3" json:"x,omitempty"` 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 = "" + const file_driverpb_driver_proto_rawDesc = "" +
"\n" + "\n" +
"\x15driverpb/driver.proto\x12\x0euatu.driver.v1\"\a\n" + "\x15driverpb/driver.proto\x12\x0euatu.driver.v1\"\a\n" +
"\x05Empty\"M\n" + "\x05Empty\"z\n" +
"\rLaunchRequest\x12\x1b\n" + "\rLaunchRequest\x12\x1b\n" +
"\tbundle_id\x18\x01 \x01(\tR\bbundleId\x12\x1f\n" + "\tbundle_id\x18\x01 \x01(\tR\bbundleId\x12\x1f\n" +
"\vclear_state\x18\x02 \x01(\bR\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" + "\x05Point\x12\f\n" +
"\x01x\x18\x01 \x01(\x05R\x01x\x12\f\n" + "\x01x\x18\x01 \x01(\x05R\x01x\x12\f\n" +
"\x01y\x18\x02 \x01(\x05R\x01y\"\x1c\n" + "\x01y\x18\x02 \x01(\x05R\x01y\"\x1c\n" +
+4
View File
@@ -26,6 +26,10 @@ message Empty {}
message LaunchRequest { message LaunchRequest {
string bundle_id = 1; string bundle_id = 1;
bool clear_state = 2; bool clear_state = 2;
// Optional "<pkg>/<activity>" 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 { message Point {
@@ -1,7 +1,7 @@
package dev.uatu.sidecar package dev.uatu.sidecar
interface DriverBackend { interface DriverBackend {
fun launch(bundleId: String, clearState: Boolean) fun launch(bundleId: String, launcherActivity: String, clearState: Boolean)
fun terminate(bundleId: String) fun terminate(bundleId: String)
fun tap(x: Int, y: Int) fun tap(x: Int, y: Int)
fun tapSelector(selector: String) fun tapSelector(selector: String)
@@ -30,47 +30,22 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
@Volatile var lastInputText: String? = null @Volatile var lastInputText: String? = null
private set private set
override fun launch(bundleId: String, clearState: Boolean) { override fun launch(bundleId: String, launcherActivity: String, clearState: Boolean) {
launchCount++ launchCount++
lastBundleId = bundleId lastBundleId = bundleId
if (clearState) { if (clearState) {
runAdb(listOf("shell", "pm", "clear", bundleId)) runAdb(listOf("shell", "pm", "clear", bundleId))
} }
val launcherComponent = resolveLauncherActivity(bundleId) if (launcherActivity.isNotEmpty()) {
if (launcherComponent != null) { val component = if (launcherActivity.contains('/')) launcherActivity else "$bundleId/$launcherActivity"
runAdb(listOf("shell", "am", "start", "-n", launcherComponent)) runAdb(listOf("shell", "am", "start", "-n", component))
} else { } 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")) 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:
// <hash> <pkg>/<activity> filter <hash>
// Action: "android.intent.action.MAIN"
// Category: "android.intent.category.LAUNCHER"
// Pick the first <pkg>/<activity> 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) { override fun terminate(bundleId: String) {
runAdb(listOf("shell", "am", "force-stop", bundleId)) runAdb(listOf("shell", "am", "force-stop", bundleId))
lastBundleId = null lastBundleId = null
@@ -95,9 +70,10 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
private fun runAdb(arguments: List<String>) { private fun runAdb(arguments: List<String>) {
try { try {
val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start() 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() command.waitFor()
} catch (cause: Exception) { } catch (cause: Exception) {
// Best-effort: log and continue.
println("adb ${arguments.joinToString(" ")} failed: $cause") println("adb ${arguments.joinToString(" ")} failed: $cause")
} }
} }
@@ -24,7 +24,7 @@ class DriverService(
override fun launch(request: LaunchRequest, responseObserver: StreamObserver<Empty>) { override fun launch(request: LaunchRequest, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) { runRpc(responseObserver) {
backend.launch(request.bundleId, request.clearState) backend.launch(request.bundleId, request.launcherActivity, request.clearState)
launchedBundleId.set(request.bundleId) launchedBundleId.set(request.bundleId)
Empty.getDefaultInstance() Empty.getDefaultInstance()
} }