diff --git a/internal/driver/sidecar/client.go b/internal/driver/sidecar/client.go index 1963409..82f68a9 100644 --- a/internal/driver/sidecar/client.go +++ b/internal/driver/sidecar/client.go @@ -104,14 +104,18 @@ func (c *Client) TapSelector(ctx context.Context, selector string) error { return err } -// doubleTapGap is the inter-tap delay for DoubleTap: short enough to land both -// events inside a sub-100 ms race window, long enough for the sidecar to -// serialize two MotionEvent streams. The sidecar exposes no native double-tap -// RPC, so the gesture is two Taps with this gap. +// doubleTapGap is the inter-tap delay for the selector fallback: short enough +// to land both events inside a sub-100 ms race window, long enough for the +// sidecar to serialize two MotionEvent streams. const doubleTapGap = 50 * time.Millisecond +// DoubleTap dispatches the native RPC so the backend can land both taps as +// close together as the platform allows. Composing two Tap round trips from +// here spreads them by hundreds of milliseconds on iOS, wide enough for +// navigation to interleave between the taps. func (c *Client) DoubleTap(ctx context.Context, x, y int) error { - return doubleTap(ctx, func() error { return c.Tap(ctx, x, y) }) + _, err := c.stub.DoubleTap(ctx, &driverpb.Point{X: int32(x), Y: int32(y)}) + return err } func (c *Client) DoubleTapSelector(ctx context.Context, selector string) error { diff --git a/proto/driverpb/driver.pb.go b/proto/driverpb/driver.pb.go index fa65e36..1ac1ceb 100644 --- a/proto/driverpb/driver.pb.go +++ b/proto/driverpb/driver.pb.go @@ -1003,11 +1003,13 @@ const file_driverpb_driver_proto_rawDesc = "" + "cpuPercent\x12\x1d\n" + "\n" + "heap_bytes\x18\x02 \x01(\x03R\theapBytes\x12,\n" + - "\x12total_memory_bytes\x18\x03 \x01(\x03R\x10totalMemoryBytes2\xd1\t\n" + + "\x12total_memory_bytes\x18\x03 \x01(\x03R\x10totalMemoryBytes2\x98\n" + + "\n" + "\x06Driver\x12J\n" + "\x06Launch\x12#.sanderling.driver.v1.LaunchRequest\x1a\x1b.sanderling.driver.v1.Empty\x12E\n" + "\tTerminate\x12\x1b.sanderling.driver.v1.Empty\x1a\x1b.sanderling.driver.v1.Empty\x12?\n" + - "\x03Tap\x12\x1b.sanderling.driver.v1.Point\x1a\x1b.sanderling.driver.v1.Empty\x12J\n" + + "\x03Tap\x12\x1b.sanderling.driver.v1.Point\x1a\x1b.sanderling.driver.v1.Empty\x12E\n" + + "\tDoubleTap\x12\x1b.sanderling.driver.v1.Point\x1a\x1b.sanderling.driver.v1.Empty\x12J\n" + "\vTapSelector\x12\x1e.sanderling.driver.v1.Selector\x1a\x1b.sanderling.driver.v1.Empty\x12D\n" + "\tInputText\x12\x1a.sanderling.driver.v1.Text\x1a\x1b.sanderling.driver.v1.Empty\x12P\n" + "\tEraseText\x12&.sanderling.driver.v1.EraseTextRequest\x1a\x1b.sanderling.driver.v1.Empty\x12H\n" + @@ -1069,37 +1071,39 @@ var file_driverpb_driver_proto_depIdxs = []int32{ 1, // 6: sanderling.driver.v1.Driver.Launch:input_type -> sanderling.driver.v1.LaunchRequest 0, // 7: sanderling.driver.v1.Driver.Terminate:input_type -> sanderling.driver.v1.Empty 2, // 8: sanderling.driver.v1.Driver.Tap:input_type -> sanderling.driver.v1.Point - 5, // 9: sanderling.driver.v1.Driver.TapSelector:input_type -> sanderling.driver.v1.Selector - 3, // 10: sanderling.driver.v1.Driver.InputText:input_type -> sanderling.driver.v1.Text - 4, // 11: sanderling.driver.v1.Driver.EraseText:input_type -> sanderling.driver.v1.EraseTextRequest - 11, // 12: sanderling.driver.v1.Driver.Swipe:input_type -> sanderling.driver.v1.SwipeRequest - 12, // 13: sanderling.driver.v1.Driver.PressKey:input_type -> sanderling.driver.v1.PressKeyRequest - 2, // 14: sanderling.driver.v1.Driver.LongPress:input_type -> sanderling.driver.v1.Point - 0, // 15: sanderling.driver.v1.Driver.Screenshot:input_type -> sanderling.driver.v1.Empty - 0, // 16: sanderling.driver.v1.Driver.Hierarchy:input_type -> sanderling.driver.v1.Empty - 0, // 17: sanderling.driver.v1.Driver.Snapshot:input_type -> sanderling.driver.v1.Empty - 13, // 18: sanderling.driver.v1.Driver.RecentLogs:input_type -> sanderling.driver.v1.RecentLogsRequest - 9, // 19: sanderling.driver.v1.Driver.WaitForIdle:input_type -> sanderling.driver.v1.Duration - 0, // 20: sanderling.driver.v1.Driver.Health:input_type -> sanderling.driver.v1.Empty - 16, // 21: sanderling.driver.v1.Driver.Metrics:input_type -> sanderling.driver.v1.MetricsRequest - 0, // 22: sanderling.driver.v1.Driver.Launch:output_type -> sanderling.driver.v1.Empty - 0, // 23: sanderling.driver.v1.Driver.Terminate:output_type -> sanderling.driver.v1.Empty - 0, // 24: sanderling.driver.v1.Driver.Tap:output_type -> sanderling.driver.v1.Empty - 0, // 25: sanderling.driver.v1.Driver.TapSelector:output_type -> sanderling.driver.v1.Empty - 0, // 26: sanderling.driver.v1.Driver.InputText:output_type -> sanderling.driver.v1.Empty - 0, // 27: sanderling.driver.v1.Driver.EraseText:output_type -> sanderling.driver.v1.Empty - 0, // 28: sanderling.driver.v1.Driver.Swipe:output_type -> sanderling.driver.v1.Empty - 0, // 29: sanderling.driver.v1.Driver.PressKey:output_type -> sanderling.driver.v1.Empty - 0, // 30: sanderling.driver.v1.Driver.LongPress:output_type -> sanderling.driver.v1.Empty - 6, // 31: sanderling.driver.v1.Driver.Screenshot:output_type -> sanderling.driver.v1.Image - 7, // 32: sanderling.driver.v1.Driver.Hierarchy:output_type -> sanderling.driver.v1.HierarchyJSON - 8, // 33: sanderling.driver.v1.Driver.Snapshot:output_type -> sanderling.driver.v1.SnapshotResponse - 14, // 34: sanderling.driver.v1.Driver.RecentLogs:output_type -> sanderling.driver.v1.LogEntries - 0, // 35: sanderling.driver.v1.Driver.WaitForIdle:output_type -> sanderling.driver.v1.Empty - 10, // 36: sanderling.driver.v1.Driver.Health:output_type -> sanderling.driver.v1.HealthStatus - 17, // 37: sanderling.driver.v1.Driver.Metrics:output_type -> sanderling.driver.v1.MetricsResponse - 22, // [22:38] is the sub-list for method output_type - 6, // [6:22] is the sub-list for method input_type + 2, // 9: sanderling.driver.v1.Driver.DoubleTap:input_type -> sanderling.driver.v1.Point + 5, // 10: sanderling.driver.v1.Driver.TapSelector:input_type -> sanderling.driver.v1.Selector + 3, // 11: sanderling.driver.v1.Driver.InputText:input_type -> sanderling.driver.v1.Text + 4, // 12: sanderling.driver.v1.Driver.EraseText:input_type -> sanderling.driver.v1.EraseTextRequest + 11, // 13: sanderling.driver.v1.Driver.Swipe:input_type -> sanderling.driver.v1.SwipeRequest + 12, // 14: sanderling.driver.v1.Driver.PressKey:input_type -> sanderling.driver.v1.PressKeyRequest + 2, // 15: sanderling.driver.v1.Driver.LongPress:input_type -> sanderling.driver.v1.Point + 0, // 16: sanderling.driver.v1.Driver.Screenshot:input_type -> sanderling.driver.v1.Empty + 0, // 17: sanderling.driver.v1.Driver.Hierarchy:input_type -> sanderling.driver.v1.Empty + 0, // 18: sanderling.driver.v1.Driver.Snapshot:input_type -> sanderling.driver.v1.Empty + 13, // 19: sanderling.driver.v1.Driver.RecentLogs:input_type -> sanderling.driver.v1.RecentLogsRequest + 9, // 20: sanderling.driver.v1.Driver.WaitForIdle:input_type -> sanderling.driver.v1.Duration + 0, // 21: sanderling.driver.v1.Driver.Health:input_type -> sanderling.driver.v1.Empty + 16, // 22: sanderling.driver.v1.Driver.Metrics:input_type -> sanderling.driver.v1.MetricsRequest + 0, // 23: sanderling.driver.v1.Driver.Launch:output_type -> sanderling.driver.v1.Empty + 0, // 24: sanderling.driver.v1.Driver.Terminate:output_type -> sanderling.driver.v1.Empty + 0, // 25: sanderling.driver.v1.Driver.Tap:output_type -> sanderling.driver.v1.Empty + 0, // 26: sanderling.driver.v1.Driver.DoubleTap:output_type -> sanderling.driver.v1.Empty + 0, // 27: sanderling.driver.v1.Driver.TapSelector:output_type -> sanderling.driver.v1.Empty + 0, // 28: sanderling.driver.v1.Driver.InputText:output_type -> sanderling.driver.v1.Empty + 0, // 29: sanderling.driver.v1.Driver.EraseText:output_type -> sanderling.driver.v1.Empty + 0, // 30: sanderling.driver.v1.Driver.Swipe:output_type -> sanderling.driver.v1.Empty + 0, // 31: sanderling.driver.v1.Driver.PressKey:output_type -> sanderling.driver.v1.Empty + 0, // 32: sanderling.driver.v1.Driver.LongPress:output_type -> sanderling.driver.v1.Empty + 6, // 33: sanderling.driver.v1.Driver.Screenshot:output_type -> sanderling.driver.v1.Image + 7, // 34: sanderling.driver.v1.Driver.Hierarchy:output_type -> sanderling.driver.v1.HierarchyJSON + 8, // 35: sanderling.driver.v1.Driver.Snapshot:output_type -> sanderling.driver.v1.SnapshotResponse + 14, // 36: sanderling.driver.v1.Driver.RecentLogs:output_type -> sanderling.driver.v1.LogEntries + 0, // 37: sanderling.driver.v1.Driver.WaitForIdle:output_type -> sanderling.driver.v1.Empty + 10, // 38: sanderling.driver.v1.Driver.Health:output_type -> sanderling.driver.v1.HealthStatus + 17, // 39: sanderling.driver.v1.Driver.Metrics:output_type -> sanderling.driver.v1.MetricsResponse + 23, // [23:40] is the sub-list for method output_type + 6, // [6:23] is the sub-list for method input_type 6, // [6:6] is the sub-list for extension type_name 6, // [6:6] is the sub-list for extension extendee 0, // [0:6] is the sub-list for field type_name diff --git a/proto/driverpb/driver.proto b/proto/driverpb/driver.proto index f193bb6..217b9ef 100644 --- a/proto/driverpb/driver.proto +++ b/proto/driverpb/driver.proto @@ -11,6 +11,11 @@ service Driver { rpc Terminate(Empty) returns (Empty); rpc Tap(Point) returns (Empty); + // DoubleTap lands two taps as close together as the platform allows. + // Composing two Tap calls from the client spreads them by hundreds of + // milliseconds on iOS, wide enough for navigation to interleave, which + // defeats double-submission testing. + rpc DoubleTap(Point) returns (Empty); rpc TapSelector(Selector) returns (Empty); rpc InputText(Text) returns (Empty); // EraseText deletes characters from the focused field so InputText can diff --git a/proto/driverpb/driver_grpc.pb.go b/proto/driverpb/driver_grpc.pb.go index 69b5b29..a15d19f 100644 --- a/proto/driverpb/driver_grpc.pb.go +++ b/proto/driverpb/driver_grpc.pb.go @@ -22,6 +22,7 @@ const ( Driver_Launch_FullMethodName = "/sanderling.driver.v1.Driver/Launch" Driver_Terminate_FullMethodName = "/sanderling.driver.v1.Driver/Terminate" Driver_Tap_FullMethodName = "/sanderling.driver.v1.Driver/Tap" + Driver_DoubleTap_FullMethodName = "/sanderling.driver.v1.Driver/DoubleTap" Driver_TapSelector_FullMethodName = "/sanderling.driver.v1.Driver/TapSelector" Driver_InputText_FullMethodName = "/sanderling.driver.v1.Driver/InputText" Driver_EraseText_FullMethodName = "/sanderling.driver.v1.Driver/EraseText" @@ -44,6 +45,11 @@ type DriverClient interface { Launch(ctx context.Context, in *LaunchRequest, opts ...grpc.CallOption) (*Empty, error) Terminate(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error) Tap(ctx context.Context, in *Point, opts ...grpc.CallOption) (*Empty, error) + // DoubleTap lands two taps as close together as the platform allows. + // Composing two Tap calls from the client spreads them by hundreds of + // milliseconds on iOS, wide enough for navigation to interleave, which + // defeats double-submission testing. + DoubleTap(ctx context.Context, in *Point, opts ...grpc.CallOption) (*Empty, error) TapSelector(ctx context.Context, in *Selector, opts ...grpc.CallOption) (*Empty, error) InputText(ctx context.Context, in *Text, opts ...grpc.CallOption) (*Empty, error) // EraseText deletes characters from the focused field so InputText can @@ -102,6 +108,16 @@ func (c *driverClient) Tap(ctx context.Context, in *Point, opts ...grpc.CallOpti return out, nil } +func (c *driverClient) DoubleTap(ctx context.Context, in *Point, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Empty) + err := c.cc.Invoke(ctx, Driver_DoubleTap_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *driverClient) TapSelector(ctx context.Context, in *Selector, opts ...grpc.CallOption) (*Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) @@ -239,6 +255,11 @@ type DriverServer interface { Launch(context.Context, *LaunchRequest) (*Empty, error) Terminate(context.Context, *Empty) (*Empty, error) Tap(context.Context, *Point) (*Empty, error) + // DoubleTap lands two taps as close together as the platform allows. + // Composing two Tap calls from the client spreads them by hundreds of + // milliseconds on iOS, wide enough for navigation to interleave, which + // defeats double-submission testing. + DoubleTap(context.Context, *Point) (*Empty, error) TapSelector(context.Context, *Selector) (*Empty, error) InputText(context.Context, *Text) (*Empty, error) // EraseText deletes characters from the focused field so InputText can @@ -276,6 +297,9 @@ func (UnimplementedDriverServer) Terminate(context.Context, *Empty) (*Empty, err func (UnimplementedDriverServer) Tap(context.Context, *Point) (*Empty, error) { return nil, status.Error(codes.Unimplemented, "method Tap not implemented") } +func (UnimplementedDriverServer) DoubleTap(context.Context, *Point) (*Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DoubleTap not implemented") +} func (UnimplementedDriverServer) TapSelector(context.Context, *Selector) (*Empty, error) { return nil, status.Error(codes.Unimplemented, "method TapSelector not implemented") } @@ -390,6 +414,24 @@ func _Driver_Tap_Handler(srv interface{}, ctx context.Context, dec func(interfac return interceptor(ctx, in, info, handler) } +func _Driver_DoubleTap_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Point) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DriverServer).DoubleTap(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Driver_DoubleTap_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DriverServer).DoubleTap(ctx, req.(*Point)) + } + return interceptor(ctx, in, info, handler) +} + func _Driver_TapSelector_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(Selector) if err := dec(in); err != nil { @@ -643,6 +685,10 @@ var Driver_ServiceDesc = grpc.ServiceDesc{ MethodName: "Tap", Handler: _Driver_Tap_Handler, }, + { + MethodName: "DoubleTap", + Handler: _Driver_DoubleTap_Handler, + }, { MethodName: "TapSelector", Handler: _Driver_TapSelector_Handler, diff --git a/proto/driverpb/driver_test.go b/proto/driverpb/driver_test.go index 3bdb713..902d405 100644 --- a/proto/driverpb/driver_test.go +++ b/proto/driverpb/driver_test.go @@ -16,6 +16,7 @@ func TestDriverServiceDescriptor(t *testing.T) { "Launch": true, "Terminate": true, "Tap": true, + "DoubleTap": true, "TapSelector": true, "InputText": true, "EraseText": true, diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index d6e668b..630a1d6 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -4,6 +4,15 @@ interface DriverBackend { fun launch(bundleId: String, clearState: Boolean, env: Map = emptyMap()) fun terminate(bundleId: String) fun tap(x: Int, y: Int) + + // doubleTap lands two taps as close together as the platform allows. + // The default composes two taps back-to-back; backends with higher + // per-tap latency override to tighten the gap. + fun doubleTap(x: Int, y: Int) { + tap(x, y) + tap(x, y) + } + fun tapSelector(selector: String) fun inputText(text: String) fun eraseText(characterCount: Int) @@ -735,6 +744,15 @@ class IosDriverBackend(private val udid: String) : DriverBackend { override fun tap(x: Int, y: Int) = withReconnect { driver.tap(maestro.Point(x, y)) } + // Both taps inside one reconnect scope, with nothing between them: the + // XCTest transport adds hundreds of milliseconds per round trip, so any + // client-side composition spreads the taps wide enough for the app to + // navigate between them. + override fun doubleTap(x: Int, y: Int) = withReconnect { + driver.tap(maestro.Point(x, y)) + driver.tap(maestro.Point(x, y)) + } + override fun longPress(x: Int, y: Int) = withReconnect { driver.longPress(maestro.Point(x, y)) } override fun tapSelector(selector: String) = withReconnect { diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt index 421a205..3fc703d 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt @@ -54,6 +54,13 @@ class DriverService( } } + override fun doubleTap(request: Point, responseObserver: StreamObserver) { + runRpc(responseObserver) { + backend.doubleTap(request.x, request.y) + Empty.getDefaultInstance() + } + } + override fun longPress(request: Point, responseObserver: StreamObserver) { runRpc(responseObserver) { backend.longPress(request.x, request.y) diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt index 073a322..ab52bab 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt @@ -83,6 +83,34 @@ class DriverServiceTest { assertEquals("hello world", backend.lastInputText) } + @Test fun doubleTapDefaultComposesTwoTaps() { + // Interface delegation would bind the default doubleTap to the + // delegate, bypassing the tap override, so implement the interface + // directly. + val taps = mutableListOf>() + val backend = object : DriverBackend { + override fun launch(bundleId: String, clearState: Boolean, env: Map) {} + override fun terminate(bundleId: String) {} + override fun tap(x: Int, y: Int) { taps.add(x to y) } + override fun tapSelector(selector: String) {} + override fun inputText(text: String) {} + override fun eraseText(characterCount: Int) {} + override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) {} + override fun pressKey(key: String) {} + override fun longPress(x: Int, y: Int) {} + override fun screenshot(): Triple = Triple(byteArrayOf(), 0, 0) + override fun hierarchy(): String = "{}" + override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List = emptyList() + override fun waitForIdle(durationMillis: Long) {} + override fun healthy(): Boolean = true + override fun metrics(bundleId: String): MetricsSample = MetricsSample(0.0, 0L, 0L) + } + val client = newClient(backend) + + client.doubleTap(Point.newBuilder().setX(120).setY(340).build()) + assertEquals(listOf(120 to 340, 120 to 340), taps) + } + @Test fun eraseTextForwardsCharacterCount() { val backend = StubDriverBackend("android") val client = newClient(backend)