mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(driver): optional launcher_activity on Launch RPC for multi-alias apps
This commit is contained in:
1 parent
6d81e24e71
commit
c4cf0ff530
11 files changed
+50
-45
No files matched your search
@@ -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")
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" +
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user