diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cdf53f67..35632507 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,20 @@ # Release History +## v4.3.3 (2023-04-14) + +**go version** + +- feat: add `sleep_random` to sleep random seconds, with weight for multiple time ranges +- feat: input text with adb +- feat: add adb `screencap` sub command +- feat: add `IsAppInForeground` to check if the given package is in foreground +- feat: check if app is in foreground when step failed +- fix: adb driver for TapFloat +- fix: stop logcat only when enabled +- fix: do not fail case when kill logcat error +- fix: take screenshot after each step +- fix: screencap compatibility for shell v1 and v2 + ## v4.3.2 (2022-12-26) **go version** diff --git a/examples/uitest/demo_feed_random_slide.json b/examples/uitest/demo_feed_random_slide.json index 86c62947..5a89c1cc 100644 --- a/examples/uitest/demo_feed_random_slide.json +++ b/examples/uitest/demo_feed_random_slide.json @@ -43,7 +43,7 @@ } }, { - "name": "滑动 Feed 35 次,随机间隔 0-20s", + "name": "滑动 Feed 3 次,随机间隔 0-5s", "android": { "actions": [ { @@ -54,15 +54,15 @@ "method": "sleep_random", "params": [ 0, - 20 + 5 ] } ] }, - "loops": 35 + "loops": 3 }, { - "name": "滑动 Feed 15 次,随机间隔 15-50s", + "name": "滑动 Feed 1 次,随机间隔 5-10s", "android": { "actions": [ { @@ -72,13 +72,36 @@ { "method": "sleep_random", "params": [ - 15, - 50 + 5, + 10 ] } ] }, - "loops": 15 + "loops": 1 + }, + { + "name": "滑动 Feed 10 次,70% 随机间隔 0-5s,30% 随机间隔 5-10s", + "android": { + "actions": [ + { + "method": "swipe", + "params": "up" + }, + { + "method": "sleep_random", + "params": [ + 0, + 5, + 0.7, + 5, + 10, + 0.3 + ] + } + ] + }, + "loops": 10 } ] } diff --git a/examples/uitest/demo_feed_random_slide_test.go b/examples/uitest/demo_feed_random_slide_test.go index cc7c5d95..ad892b87 100644 --- a/examples/uitest/demo_feed_random_slide_test.go +++ b/examples/uitest/demo_feed_random_slide_test.go @@ -25,16 +25,21 @@ func TestAndroidDouyinFeedTest(t *testing.T) { hrp.NewStep("处理青少年弹窗"). Android(). TapByOCR("我知道了", uixt.WithIgnoreNotFoundError(true)), - hrp.NewStep("滑动 Feed 35 次,随机间隔 0-20s"). - Loop(35). + hrp.NewStep("滑动 Feed 3 次,随机间隔 0-5s"). + Loop(3). Android(). SwipeUp(). - SleepRandom(0, 20), - hrp.NewStep("滑动 Feed 15 次,随机间隔 15-50s"). - Loop(15). + SleepRandom(0, 5), + hrp.NewStep("滑动 Feed 1 次,随机间隔 5-10s"). + Loop(1). Android(). SwipeUp(). - SleepRandom(15, 50), + SleepRandom(5, 10), + hrp.NewStep("滑动 Feed 10 次,70% 随机间隔 0-5s,30% 随机间隔 5-10s"). + Loop(10). + Android(). + SwipeUp(). + SleepRandom(0, 5, 0.7, 5, 10, 0.3), }, } diff --git a/go.mod b/go.mod index 5a79687c..593fc47d 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/spf13/cobra v1.5.0 github.com/stretchr/testify v1.8.0 gocv.io/x/gocv v0.31.0 - golang.org/x/net v0.0.0-20220919232410-f2f64ebce3c1 + golang.org/x/net v0.7.0 golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 google.golang.org/grpc v1.49.0 google.golang.org/protobuf v1.28.1 @@ -75,8 +75,8 @@ require ( github.com/yusufpapurcu/wmi v1.2.2 // indirect golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect golang.org/x/sync v0.0.0-20220907140024-f12130a52804 // indirect - golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/text v0.7.0 // indirect golang.org/x/tools v0.1.12 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51 // indirect diff --git a/go.sum b/go.sum index 1cc8206d..c40a64ef 100644 --- a/go.sum +++ b/go.sum @@ -513,8 +513,8 @@ golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220919232410-f2f64ebce3c1 h1:TWZxd/th7FbRSMret2MVQdlI8uT49QEtwZdvJrxjEHU= -golang.org/x/net v0.0.0-20220919232410-f2f64ebce3c1/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -626,8 +626,8 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 h1:h+EGohizhe9XlX18rfpa8k8RAc5XyaeamM+0VHRd4lc= -golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -639,8 +639,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/hrp/cmd/adb/devices.go b/hrp/cmd/adb/devices.go index 86c93608..3a0de351 100644 --- a/hrp/cmd/adb/devices.go +++ b/hrp/cmd/adb/devices.go @@ -5,10 +5,8 @@ import ( "fmt" "os" - "github.com/pkg/errors" "github.com/spf13/cobra" - "github.com/httprunner/httprunner/v4/hrp/pkg/gadb" "github.com/httprunner/httprunner/v4/hrp/pkg/uixt" ) @@ -21,22 +19,10 @@ var listAndroidDevicesCmd = &cobra.Command{ Use: "devices", Short: "List all Android devices", RunE: func(cmd *cobra.Command, args []string) error { - devices, err := uixt.DeviceList() + deviceList, err := uixt.GetAndroidDevices(serial) if err != nil { - return errors.Wrap(err, "list android devices failed") - } - - var deviceList []gadb.Device - // filter by serial - for _, d := range devices { - if serial != "" && serial != d.Serial() { - continue - } - deviceList = append(deviceList, d) - } - if serial != "" && len(deviceList) == 0 { - fmt.Printf("no android device found for serial: %s\n", serial) - os.Exit(1) + fmt.Println(err) + os.Exit(0) } for _, d := range deviceList { diff --git a/hrp/cmd/adb/init.go b/hrp/cmd/adb/init.go index 9025ef70..e2a86bce 100644 --- a/hrp/cmd/adb/init.go +++ b/hrp/cmd/adb/init.go @@ -1,6 +1,13 @@ package adb -import "github.com/spf13/cobra" +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/httprunner/httprunner/v4/hrp/pkg/gadb" + "github.com/httprunner/httprunner/v4/hrp/pkg/uixt" +) var androidRootCmd = &cobra.Command{ Use: "adb", @@ -8,6 +15,17 @@ var androidRootCmd = &cobra.Command{ PersistentPreRun: func(cmd *cobra.Command, args []string) {}, } +func getDevice(serial string) (*gadb.Device, error) { + devices, err := uixt.GetAndroidDevices(serial) + if err != nil { + return nil, err + } + if len(devices) > 1 { + return nil, fmt.Errorf("found multiple attached devices, please specify android serial") + } + return devices[0], nil +} + func Init(rootCmd *cobra.Command) { rootCmd.AddCommand(androidRootCmd) } diff --git a/hrp/cmd/adb/screencap.go b/hrp/cmd/adb/screencap.go new file mode 100644 index 00000000..d9166191 --- /dev/null +++ b/hrp/cmd/adb/screencap.go @@ -0,0 +1,37 @@ +package adb + +import ( + "fmt" + "io/ioutil" + "time" + + "github.com/spf13/cobra" +) + +var screencapAndroidDevicesCmd = &cobra.Command{ + Use: "screencap", + Short: "Start android screen capture", + RunE: func(cmd *cobra.Command, args []string) error { + device, err := getDevice(serial) + if err != nil { + return err + } + + res, err := device.ScreenCap() + if err != nil { + return err + } + + filepath := fmt.Sprintf("screencap_%d.png", time.Now().Unix()) + if err = ioutil.WriteFile(filepath, res, 0o644); err != nil { + return err + } + fmt.Println("screencap saved to", filepath) + return nil + }, +} + +func init() { + screencapAndroidDevicesCmd.Flags().StringVarP(&serial, "serial", "s", "", "filter by device's serial") + androidRootCmd.AddCommand(screencapAndroidDevicesCmd) +} diff --git a/hrp/cmd/ios/devices.go b/hrp/cmd/ios/devices.go index c1f230d3..ad8fb55c 100644 --- a/hrp/cmd/ios/devices.go +++ b/hrp/cmd/ios/devices.go @@ -70,18 +70,10 @@ var listDevicesCmd = &cobra.Command{ Short: "List all iOS devices", PersistentPreRun: func(cmd *cobra.Command, args []string) {}, RunE: func(cmd *cobra.Command, args []string) error { - devices, err := uixt.IOSDevices(udid) + devices, err := uixt.GetIOSDevices(udid) if err != nil { - return err - } - if len(devices) == 0 { - if udid != "" { - fmt.Printf("no ios device found for udid: %s\n", udid) - os.Exit(1) - } else { - fmt.Println("no ios device found") - os.Exit(0) - } + fmt.Println(err) + os.Exit(0) } for _, d := range devices { diff --git a/hrp/cmd/ios/init.go b/hrp/cmd/ios/init.go index 209846fb..8ec31071 100644 --- a/hrp/cmd/ios/init.go +++ b/hrp/cmd/ios/init.go @@ -2,7 +2,6 @@ package ios import ( "fmt" - "os" "github.com/spf13/cobra" @@ -16,16 +15,12 @@ var iosRootCmd = &cobra.Command{ } func getDevice(udid string) (gidevice.Device, error) { - devices, err := uixt.IOSDevices(udid) + devices, err := uixt.GetIOSDevices(udid) if err != nil { return nil, err } - if len(devices) == 0 { - fmt.Println("no ios device found") - os.Exit(1) - } if len(devices) > 1 { - return nil, fmt.Errorf("multiple devices found, please specify udid") + return nil, fmt.Errorf("found multiple attached devices, please specify ios udid") } return devices[0], nil } diff --git a/hrp/internal/code/code.go b/hrp/internal/code/code.go index 4687bc96..354ff302 100644 --- a/hrp/internal/code/code.go +++ b/hrp/internal/code/code.go @@ -67,8 +67,9 @@ var ( // UI automation related: [70, 80) var ( - MobileUIDriverError = errors.New("mobile UI driver error") // 70 - MobileUIValidationError = errors.New("mobile UI validation error") // 75 + MobileUIDriverError = errors.New("mobile UI driver error") // 70 + MobileUIValidationError = errors.New("mobile UI validation error") // 75 + MobileUIAppNotInForegroundError = errors.New("mobile UI app not in foreground error") // 76 ) // OCR related: [80, 90) diff --git a/hrp/internal/version/VERSION b/hrp/internal/version/VERSION index b0136b0c..f907911d 100644 --- a/hrp/internal/version/VERSION +++ b/hrp/internal/version/VERSION @@ -1 +1 @@ -v4.3.2 \ No newline at end of file +v4.3.3.2304142356 \ No newline at end of file diff --git a/hrp/pkg/gadb/README.md b/hrp/pkg/gadb/README.md index dc67b810..2027cea5 100644 --- a/hrp/pkg/gadb/README.md +++ b/hrp/pkg/gadb/README.md @@ -1,5 +1,11 @@ # gadb -This module is initially forked from [electricbubble/gadb@v0.0.7]. +This module is initially forked from [electricbubble/gadb@v0.0.7] and optimized by [@appl3s]. + +- feat: add reverse forward command +- feat: add `RunShellCommandV2` which supports running nohup +- feat: add `InstallAPK` with feature judgment +- feat: add `Uninstall` for specified package name [electricbubble/gadb@v0.0.7]: https://github.com/electricbubble/gadb/tree/v0.0.7 +[@appl3s]: https://github.com/appl3s diff --git a/hrp/pkg/gadb/client.go b/hrp/pkg/gadb/client.go index 4543484a..bc0349ff 100644 --- a/hrp/pkg/gadb/client.go +++ b/hrp/pkg/gadb/client.go @@ -38,6 +38,16 @@ func NewClientWith(host string, port ...int) (adbClient Client, err error) { return } +func NewClientWithoutTransport(host string, port ...int) (adbClient Client, err error) { + if len(port) == 0 { + port = []int{AdbServerPort} + } + adbClient.host = host + adbClient.port = port[0] + + return +} + func (c Client) ServerVersion() (version int, err error) { var resp string if resp, err = c.executeCommand("host:version"); err != nil { @@ -73,14 +83,14 @@ func (c Client) DeviceSerialList() (serials []string, err error) { return } -func (c Client) DeviceList() (devices []Device, err error) { +func (c Client) DeviceList() (devices []*Device, err error) { var resp string if resp, err = c.executeCommand("host:devices-l"); err != nil { return } lines := strings.Split(resp, "\n") - devices = make([]Device, 0, len(lines)) + devices = make([]*Device, 0, len(lines)) for i := range lines { line := strings.TrimSpace(lines[i]) @@ -101,7 +111,7 @@ func (c Client) DeviceList() (devices []Device, err error) { key, val := split[0], split[1] mapAttrs[key] = val } - devices = append(devices, Device{adbClient: c, serial: fields[0], attrs: mapAttrs}) + devices = append(devices, &Device{adbClient: c, serial: fields[0], attrs: mapAttrs}) } return diff --git a/hrp/pkg/gadb/client_test.go b/hrp/pkg/gadb/client_test.go index 404af6a8..56dd1a94 100644 --- a/hrp/pkg/gadb/client_test.go +++ b/hrp/pkg/gadb/client_test.go @@ -3,6 +3,7 @@ package gadb import ( + "io/ioutil" "testing" ) @@ -127,3 +128,22 @@ func TestClient_KillServer(t *testing.T) { t.Fatal(err) } } + +func TestScreenCap(t *testing.T) { + adbClient, err := NewClient() + if err != nil { + t.Fatal(err) + } + + dl, err := adbClient.DeviceList() + if err != nil { + t.Error(err) + } + d := dl[0] + res, err := d.ScreenCap() + if err != nil { + t.Error(err) + } + t.Log(len(res)) + ioutil.WriteFile("/tmp/1.png", res, 0o644) +} diff --git a/hrp/pkg/gadb/device.go b/hrp/pkg/gadb/device.go index 59c4d1e6..181609b0 100644 --- a/hrp/pkg/gadb/device.go +++ b/hrp/pkg/gadb/device.go @@ -1,6 +1,8 @@ package gadb import ( + "bytes" + "encoding/binary" "errors" "fmt" "io" @@ -48,9 +50,10 @@ func deviceStateConv(k string) (deviceState DeviceState) { } type DeviceForward struct { - Serial string - Local string - Remote string + Serial string + Local string + Remote string + Reverse bool // LocalProtocol string // RemoteProtocol string } @@ -59,51 +62,92 @@ type Device struct { adbClient Client serial string attrs map[string]string + feat Features } -func (d Device) Product() string { +func (d *Device) HasFeature(name Feature) bool { + feats, err := d.GetFeatures() + if err != nil || len(feats) == 0 { + return false + } + return feats.HasFeature(name) +} + +func (d *Device) GetFeatures() (features Features, err error) { + if len(d.feat) > 0 { + return d.feat, nil + } + return d.features() +} + +func (d *Device) features() (features Features, err error) { + res, err := d.executeCommand("host:features") + if err != nil { + return nil, err + } + if len(res) > 4 { + // stip hash + res = res[4:] + } + fs := strings.Split(string(res), ",") + features = make(Features, len(fs)) + for _, f := range fs { + features[Feature(f)] = struct{}{} + } + d.feat = features + return features, nil +} + +func (d *Device) Product() string { return d.attrs["product"] } -func (d Device) Model() string { +func (d *Device) Model() string { return d.attrs["model"] } -func (d Device) Usb() string { +func (d *Device) Usb() string { return d.attrs["usb"] } -func (d Device) transportId() string { +func (d *Device) transportId() string { return d.attrs["transport_id"] } -func (d Device) DeviceInfo() map[string]string { +func (d *Device) DeviceInfo() map[string]string { return d.attrs } -func (d Device) Serial() string { +func (d *Device) Serial() string { // resp, err := d.adbClient.executeCommand(fmt.Sprintf("host-serial:%s:get-serialno", d.serial)) return d.serial } -func (d Device) IsUsb() bool { +func (d *Device) IsUsb() bool { return d.Usb() != "" } -func (d Device) State() (DeviceState, error) { +func (d *Device) State() (DeviceState, error) { resp, err := d.adbClient.executeCommand(fmt.Sprintf("host-serial:%s:get-state", d.serial)) return deviceStateConv(resp), err } -func (d Device) DevicePath() (string, error) { +func (d *Device) DevicePath() (string, error) { resp, err := d.adbClient.executeCommand(fmt.Sprintf("host-serial:%s:get-devpath", d.serial)) return resp, err } -func (d Device) Forward(localPort, remotePort int, noRebind ...bool) (err error) { +func (d *Device) Forward(localPort int, remoteInterface interface{}, noRebind ...bool) (err error) { command := "" + var remote string local := fmt.Sprintf("tcp:%d", localPort) - remote := fmt.Sprintf("tcp:%d", remotePort) + switch r := remoteInterface.(type) { + // for unix sockets + case string: + remote = r + case int: + remote = fmt.Sprintf("tcp:%d", r) + } if len(noRebind) != 0 && noRebind[0] { command = fmt.Sprintf("host-serial:%s:forward:norebind:%s;%s", d.serial, local, remote) @@ -115,7 +159,7 @@ func (d Device) Forward(localPort, remotePort int, noRebind ...bool) (err error) return } -func (d Device) ForwardList() (deviceForwardList []DeviceForward, err error) { +func (d *Device) ForwardList() (deviceForwardList []DeviceForward, err error) { var forwardList []DeviceForward if forwardList, err = d.adbClient.ForwardList(); err != nil { return nil, err @@ -131,18 +175,80 @@ func (d Device) ForwardList() (deviceForwardList []DeviceForward, err error) { return } -func (d Device) ForwardKill(localPort int) (err error) { +func (d *Device) ForwardKill(localPort int) (err error) { local := fmt.Sprintf("tcp:%d", localPort) _, err = d.adbClient.executeCommand(fmt.Sprintf("host-serial:%s:killforward:%s", d.serial, local), true) return } -func (d Device) RunShellCommand(cmd string, args ...string) (string, error) { +func (d *Device) ReverseForward(localPort int, remoteInterface interface{}, noRebind ...bool) (err error) { + var command string + var remote string + local := fmt.Sprintf("tcp:%d", localPort) + switch r := remoteInterface.(type) { + // for unix sockets + case string: + remote = r + case int: + remote = fmt.Sprintf("tcp:%d", r) + } + + if len(noRebind) != 0 && noRebind[0] { + command = fmt.Sprintf("reverse:forward:norebind:%s;%s", remote, local) + } else { + command = fmt.Sprintf("reverse:forward:%s;%s", remote, local) + } + _, err = d.executeCommand(command, true) + return +} + +func (d *Device) ReverseForwardList() (deviceForwardList []DeviceForward, err error) { + res, err := d.executeCommand("reverse:list-forward") + if err != nil { + return nil, err + } + resStr := string(res) + lines := strings.Split(resStr, "\n") + for _, line := range lines { + groups := strings.Split(line, " ") + if len(groups) == 3 { + deviceForwardList = append(deviceForwardList, DeviceForward{ + Reverse: true, + Serial: d.serial, + Remote: groups[1], + Local: groups[2], + }) + } + } + return +} + +func (d *Device) ReverseForwardKill(remoteInterface interface{}) error { + remote := "" + switch r := remoteInterface.(type) { + case string: + remote = r + case int: + remote = fmt.Sprintf("tcp:%d", r) + } + _, err := d.executeCommand(fmt.Sprintf("reverse:killforward:%s", remote), true) + return err +} + +func (d *Device) ReverseForwardKillAll() error { + _, err := d.executeCommand("reverse:killforward-all") + return err +} + +func (d *Device) RunShellCommand(cmd string, args ...string) (string, error) { raw, err := d.RunShellCommandWithBytes(cmd, args...) return string(raw), err } -func (d Device) RunShellCommandWithBytes(cmd string, args ...string) ([]byte, error) { +func (d *Device) RunShellCommandWithBytes(cmd string, args ...string) ([]byte, error) { + if d.HasFeature(FeatShellV2) { + return d.RunShellCommandV2WithBytes(cmd, args...) + } if len(args) > 0 { cmd = fmt.Sprintf("%s %s", cmd, strings.Join(args, " ")) } @@ -156,7 +262,86 @@ func (d Device) RunShellCommandWithBytes(cmd string, args ...string) ([]byte, er return raw, err } -func (d Device) EnableAdbOverTCP(port ...int) (err error) { +func (d *Device) RunShellCommandV2(cmd string, args ...string) (string, error) { + raw, err := d.RunShellCommandV2WithBytes(cmd, args...) + return string(raw), err +} + +// RunShellCommandV2WithBytes shell v2, 支持后台运行而不会阻断 +func (d *Device) RunShellCommandV2WithBytes(cmd string, args ...string) ([]byte, error) { + if len(args) > 0 { + cmd = fmt.Sprintf("%s %s", cmd, strings.Join(args, " ")) + } + if strings.TrimSpace(cmd) == "" { + return nil, errors.New("adb shell: command cannot be empty") + } + log.Debug().Str("cmd", + fmt.Sprintf("adb -s %s shell %s", d.serial, cmd)). + Msg("run adb command in v2") + raw, err := d.executeCommand(fmt.Sprintf("shell,v2,raw:%s", cmd)) + if err != nil { + return raw, err + } + return d.parseV2CommandWithBytes(raw) +} + +func (d *Device) parseV2CommandWithBytes(input []byte) (res []byte, err error) { + if len(input) == 0 { + return input, nil + } + reader := bytes.NewReader(input) + sizeBuf := make([]byte, 4) + var ( + resBuf []byte + exitCode int + ) +loop: + for { + msgCode, err := reader.ReadByte() + if err != nil { + return input, err + } + switch msgCode { + case 0x01, 0x02: // STDOUT, STDERR + _, err = io.ReadFull(reader, sizeBuf) + if err != nil { + return input, err + } + size := binary.LittleEndian.Uint32(sizeBuf) + if cap(resBuf) < int(size) { + resBuf = make([]byte, int(size)) + } + _, err = io.ReadFull(reader, resBuf[:size]) + if err != nil { + return input, err + } + res = append(res, resBuf[:size]...) + case 0x03: // EXIT + _, err = io.ReadFull(reader, sizeBuf) + if err != nil { + return input, err + } + size := binary.LittleEndian.Uint32(sizeBuf) + if cap(resBuf) < int(size) { + resBuf = make([]byte, int(size)) + } + ec, err := reader.ReadByte() + if err != nil { + return input, err + } + exitCode = int(ec) + break loop + default: + return input, nil + } + } + if exitCode != 0 { + return nil, errors.New(string(res)) + } + return res, nil +} + +func (d *Device) EnableAdbOverTCP(port ...int) (err error) { if len(port) == 0 { port = []int{AdbDaemonPort} } @@ -168,7 +353,7 @@ func (d Device) EnableAdbOverTCP(port ...int) (err error) { return } -func (d Device) createDeviceTransport() (tp transport, err error) { +func (d *Device) createDeviceTransport() (tp transport, err error) { if tp, err = newTransport(fmt.Sprintf("%s:%d", d.adbClient.host, d.adbClient.port)); err != nil { return transport{}, err } @@ -180,7 +365,7 @@ func (d Device) createDeviceTransport() (tp transport, err error) { return } -func (d Device) executeCommand(command string, onlyVerifyResponse ...bool) (raw []byte, err error) { +func (d *Device) executeCommand(command string, onlyVerifyResponse ...bool) (raw []byte, err error) { if len(onlyVerifyResponse) == 0 { onlyVerifyResponse = []bool{false} } @@ -207,7 +392,7 @@ func (d Device) executeCommand(command string, onlyVerifyResponse ...bool) (raw return } -func (d Device) List(remotePath string) (devFileInfos []DeviceFileInfo, err error) { +func (d *Device) List(remotePath string) (devFileInfos []DeviceFileInfo, err error) { var tp transport if tp, err = d.createDeviceTransport(); err != nil { return nil, err @@ -237,7 +422,7 @@ func (d Device) List(remotePath string) (devFileInfos []DeviceFileInfo, err erro return } -func (d Device) PushFile(local *os.File, remotePath string, modification ...time.Time) (err error) { +func (d *Device) PushFile(local *os.File, remotePath string, modification ...time.Time) (err error) { if len(modification) == 0 { var stat os.FileInfo if stat, err = local.Stat(); err != nil { @@ -249,7 +434,7 @@ func (d Device) PushFile(local *os.File, remotePath string, modification ...time return d.Push(local, remotePath, modification[0], DefaultFileMode) } -func (d Device) Push(source io.Reader, remotePath string, modification time.Time, mode ...os.FileMode) (err error) { +func (d *Device) Push(source io.Reader, remotePath string, modification time.Time, mode ...os.FileMode) (err error) { if len(mode) == 0 { mode = []os.FileMode{DefaultFileMode} } @@ -285,7 +470,7 @@ func (d Device) Push(source io.Reader, remotePath string, modification time.Time return } -func (d Device) Pull(remotePath string, dest io.Writer) (err error) { +func (d *Device) Pull(remotePath string, dest io.Writer) (err error) { var tp transport if tp, err = d.createDeviceTransport(); err != nil { return err @@ -305,3 +490,102 @@ func (d Device) Pull(remotePath string, dest io.Writer) (err error) { err = sync.WriteStream(dest) return } + +func (d *Device) installViaABBExec(apk io.ReadSeeker) (raw []byte, err error) { + var ( + tp transport + filesize int64 + ) + filesize, err = apk.Seek(0, io.SeekEnd) + if err != nil { + return nil, err + } + if tp, err = d.createDeviceTransport(); err != nil { + return nil, err + } + defer func() { _ = tp.Close() }() + if err = tp.Send(fmt.Sprintf("abb_exec:package\x00install\x00-t\x00-S\x00%d", filesize)); err != nil { + return nil, err + } + + if err = tp.VerifyResponse(); err != nil { + return nil, err + } + _, err = apk.Seek(0, io.SeekStart) + if err != nil { + return nil, err + } + _, err = io.Copy(tp.Conn(), apk) + if err != nil { + return nil, err + } + raw, err = tp.ReadBytesAll() + return +} + +func (d *Device) InstallAPK(apk io.ReadSeeker) (string, error) { + haserr := func(ret string) bool { + return strings.Contains(ret, "Failure") + } + if d.HasFeature(FeatAbbExec) { + raw, err := d.installViaABBExec(apk) + if err != nil { + return "", fmt.Errorf("error installing: %v", err) + } + if haserr(string(raw)) { + return "", errors.New(string(raw)) + } + return string(raw), err + } + + remote := fmt.Sprintf("/data/local/tmp/gadb_remote_%d.apk", time.Now().Unix()) + err := d.Push(apk, remote, time.Now()) + if err != nil { + return "", fmt.Errorf("error pushing: %v", err) + } + + res, err := d.RunShellCommand("pm", "install", "-f", remote) + if err != nil { + return "", fmt.Errorf("error installing: %v", err) + } + if haserr(res) { + return "", errors.New(res) + } + + return res, nil +} + +func (d *Device) Uninstall(packageName string, keepData ...bool) (string, error) { + if len(keepData) == 0 { + keepData = []bool{false} + } + packageName = strings.ReplaceAll(packageName, " ", "") + if len(packageName) == 0 { + return "", fmt.Errorf("invalid package name") + } + args := []string{"uninstall"} + if keepData[0] { + args = append(args, "-k") + } + args = append(args, packageName) + return d.RunShellCommandV2("pm", args...) +} + +func (d *Device) ScreenCap() ([]byte, error) { + if d.HasFeature(FeatShellV2) { + return d.RunShellCommandV2WithBytes("screencap", "-p") + } + + // for shell v1, screenshot buffer maybe truncated + // thus we firstly save it to local file and then pull it + tempPath := fmt.Sprintf("/data/local/tmp/screenshot_%d.png", + time.Now().Unix()) + _, err := d.RunShellCommandWithBytes("screencap", "-p", tempPath) + if err != nil { + return nil, err + } + + buffer := bytes.NewBuffer(nil) + err = d.Pull(tempPath, buffer) + return buffer.Bytes(), err +} diff --git a/hrp/pkg/gadb/device_test.go b/hrp/pkg/gadb/device_test.go index 70ae4889..29f48b4d 100644 --- a/hrp/pkg/gadb/device_test.go +++ b/hrp/pkg/gadb/device_test.go @@ -6,6 +6,7 @@ import ( "bytes" "io/ioutil" "os" + "reflect" "strings" "testing" "time" @@ -145,6 +146,42 @@ func TestDevice_Forward(t *testing.T) { } } +func TestDevice_ReverseForward(t *testing.T) { + adbClient, err := NewClient() + if err != nil { + t.Fatal(err) + } + + devices, err := adbClient.DeviceList() + if err != nil { + t.Fatal(err) + } + + localPort := 5005 + err = devices[0].ReverseForward(localPort, "localabstract:scrcpy") + if err != nil { + t.Fatal(err) + } + err = devices[0].ReverseForward(localPort, "localabstract:scrcpy1") + if err != nil { + t.Fatal(err) + } + + _, err = devices[0].ReverseForwardList() + if err != nil { + t.Fatal(err) + } + + err = devices[0].ReverseForwardKill("localabstract:scrcpy1") + if err != nil { + t.Fatal(err) + } + err = devices[0].ReverseForwardKillAll() + if err != nil { + t.Fatal(err) + } +} + func TestDevice_ForwardList(t *testing.T) { adbClient, err := NewClient() if err != nil { @@ -314,3 +351,94 @@ func TestDevice_Pull(t *testing.T) { t.Fatal(err) } } + +func TestDevice_RunShellCommandBackgroundWithBytes(t *testing.T) { + type fields struct { + adbClient Client + serial string + attrs map[string]string + } + type args struct { + cmd string + args []string + } + tests := []struct { + name string + fields fields + args args + want []byte + wantErr bool + }{ + { + name: "runShellCommandBackground", + fields: fields{ + adbClient: func() Client { + c, _ := NewClient() + return c + }(), + serial: "63c1ee94", + }, + args: args{ + cmd: "nohup sleep 10 2>/dev/null 1>/dev/null &", + // cmd: "sleep 10", + + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := Device{ + adbClient: tt.fields.adbClient, + serial: tt.fields.serial, + attrs: tt.fields.attrs, + } + got, err := d.RunShellCommandV2WithBytes(tt.args.cmd, tt.args.args...) + if (err != nil) != tt.wantErr { + t.Errorf("Device.RunShellCommandBackgroundWithBytes() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Device.RunShellCommandBackgroundWithBytes() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDevice_InstallAPK(t *testing.T) { + apk, _ := os.Open("test.apk") + adbClient, err := NewClient() + if err != nil { + t.Fatal(err) + } + + devices, err := adbClient.DeviceList() + if err != nil { + t.Fatal(err) + } + + dev := devices[len(devices)-1] + dev = devices[0] + + res, err := dev.InstallAPK(apk) + if err != nil { + t.Fatal(err) + } + t.Log(res) +} + +func TestDevice_HasFeature(t *testing.T) { + adbClient, err := NewClient() + if err != nil { + t.Fatal(err) + } + + devices, err := adbClient.DeviceList() + if err != nil { + t.Fatal(err) + } + + dev := devices[len(devices)-1] + dev = devices[0] + + t.Log(dev.GetFeatures()) +} diff --git a/hrp/pkg/gadb/features.go b/hrp/pkg/gadb/features.go new file mode 100644 index 00000000..806f175c --- /dev/null +++ b/hrp/pkg/gadb/features.go @@ -0,0 +1,26 @@ +package gadb + +type ( + Feature string + Features map[Feature]struct{} +) + +var ( + FeatSendrecvV2Brotli = Feature("sendrecv_v2_brotli") + FeatRemountShell = Feature("remount_shell") + FeatSendrecvV2 = Feature("sendrecv_v2") + FeatAbbExec = Feature("abb_exec") + FeatFixedPushMkdir = Feature("fixed_push_mkdir") + FeatFixedPushSymlinkTimestamp = Feature("fixed_push_symlink_timestamp") + FeatAbb = Feature("abb") + FeatShellV2 = Feature("shell_v2") + FeatCmd = Feature("cmd") + FeatLsV2 = Feature("ls_v2") + FeatApex = Feature("apex") + FeatStatV2 = Feature("stat_v2") +) + +func (fs Features) HasFeature(name Feature) bool { + _, has := fs[name] + return has +} diff --git a/hrp/pkg/gadb/sync_transport.go b/hrp/pkg/gadb/sync_transport.go index 6e55df6b..ff7346a4 100644 --- a/hrp/pkg/gadb/sync_transport.go +++ b/hrp/pkg/gadb/sync_transport.go @@ -250,5 +250,6 @@ func (sync syncTransport) Close() (err error) { if sync.sock == nil { return nil } + _ = DisableTimeWait(sync.sock.(*net.TCPConn)) return sync.sock.Close() } diff --git a/hrp/pkg/gadb/transport.go b/hrp/pkg/gadb/transport.go index ae900429..c450d053 100644 --- a/hrp/pkg/gadb/transport.go +++ b/hrp/pkg/gadb/transport.go @@ -37,6 +37,14 @@ func (t transport) Send(command string) (err error) { return _send(t.sock, []byte(msg)) } +func (t transport) SendBytes(b []byte) (err error) { + return _send(t.sock, b) +} + +func (t transport) Conn() net.Conn { + return t.sock +} + func (t transport) VerifyResponse() (err error) { var status string if status, err = t.ReadStringN(4); err != nil { @@ -103,6 +111,7 @@ func (t transport) Close() (err error) { if t.sock == nil { return nil } + _ = DisableTimeWait(t.sock.(*net.TCPConn)) return t.sock.Close() } diff --git a/hrp/pkg/gadb/transport_test.go b/hrp/pkg/gadb/transport_test.go index 143cd438..2610a19e 100644 --- a/hrp/pkg/gadb/transport_test.go +++ b/hrp/pkg/gadb/transport_test.go @@ -13,7 +13,6 @@ func Test_transport_VerifyResponse(t *testing.T) { } defer transport.Close() - // err = transport.Send("host:123version") err = transport.Send("host:version") if err != nil { t.Fatal(err) diff --git a/hrp/pkg/gadb/utils.go b/hrp/pkg/gadb/utils.go new file mode 100644 index 00000000..e6536c37 --- /dev/null +++ b/hrp/pkg/gadb/utils.go @@ -0,0 +1,9 @@ +package gadb + +import ( + "net" +) + +func DisableTimeWait(conn *net.TCPConn) error { + return conn.SetLinger(0) +} diff --git a/hrp/pkg/uixt/android_adb_driver.go b/hrp/pkg/uixt/android_adb_driver.go index 394bf14e..f373f924 100644 --- a/hrp/pkg/uixt/android_adb_driver.go +++ b/hrp/pkg/uixt/android_adb_driver.go @@ -17,7 +17,7 @@ import ( type adbDriver struct { Driver - adbClient gadb.Device + adbClient *gadb.Device logcat *AdbLogcat } @@ -153,11 +153,11 @@ func (ad *adbDriver) PressKeyCode(keyCode KeyCode, metaState KeyMeta) (err error return } -func (ad *adbDriver) AppLaunch(bundleId string) (err error) { +func (ad *adbDriver) AppLaunch(packageName string) (err error) { // 不指定 Activity 名称启动(启动主 Activity) // adb shell monkey -p -c android.intent.category.LAUNCHER 1 sOutput, err := ad.adbClient.RunShellCommand( - "monkey", "-p", bundleId, "-c", "android.intent.category.LAUNCHER", "1", + "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1", ) if err != nil { return err @@ -165,14 +165,22 @@ func (ad *adbDriver) AppLaunch(bundleId string) (err error) { if strings.Contains(sOutput, "monkey aborted") { return fmt.Errorf("app launch: %s", strings.TrimSpace(sOutput)) } + ad.lastLaunchedPackageName = packageName return nil } -func (ad *adbDriver) AppTerminate(bundleId string) (successful bool, err error) { +func (ad *adbDriver) AppTerminate(packageName string) (successful bool, err error) { // 强制停止应用,停止 相关的进程 // adb shell am force-stop - _, err = ad.adbClient.RunShellCommand("am", "force-stop", bundleId) - return err == nil, err + _, err = ad.adbClient.RunShellCommand("am", "force-stop", packageName) + if err != nil { + return false, err + } + + if ad.lastLaunchedPackageName == packageName { + ad.lastLaunchedPackageName = "" // reset last launched package name + } + return true, nil } func (ad *adbDriver) Tap(x, y int, options ...DataOption) error { @@ -280,9 +288,7 @@ func (ad *adbDriver) SetRotation(rotation Rotation) (err error) { func (ad *adbDriver) Screenshot() (raw *bytes.Buffer, err error) { // adb shell screencap -p - resp, err := ad.adbClient.RunShellCommandWithBytes( - "screencap", "-p", - ) + resp, err := ad.adbClient.ScreenCap() if err == nil { return bytes.NewBuffer(resp), nil } @@ -323,7 +329,7 @@ func (ad *adbDriver) StartCaptureLog(identifier ...string) (err error) { log.Info().Msg("start adb log recording") // clear logcat - if _, err = ad.adbClient.RunShellCommand("logcat", "--clear"); err != nil { + if _, err = ad.adbClient.RunShellCommand("logcat", "-c"); err != nil { return err } @@ -349,3 +355,34 @@ func (ad *adbDriver) StopCaptureLog() (result interface{}, err error) { content := ad.logcat.logBuffer.String() return ConvertPoints(content), nil } + +func (ad *adbDriver) GetLastLaunchedApp() (packageName string) { + return ad.lastLaunchedPackageName +} + +func (ad *adbDriver) IsAppInForeground(packageName string) (bool, error) { + if packageName == "" { + return false, errors.New("package name is not given") + } + + // adb shell dumpsys activity activities | grep mResumedActivity + output, err := ad.adbClient.RunShellCommand("dumpsys", "activity", "activities") + if err != nil { + return false, err + } + + lines := strings.Split(string(output), "\n") + isInForeground := false + + for _, line := range lines { + trimmedLine := strings.TrimSpace(line) + if strings.HasPrefix(trimmedLine, "mResumedActivity:") { + if strings.Contains(trimmedLine, packageName) { + isInForeground = true + } + break + } + } + + return isInForeground, nil +} diff --git a/hrp/pkg/uixt/android_device.go b/hrp/pkg/uixt/android_device.go index 83a46d27..b511659d 100644 --- a/hrp/pkg/uixt/android_device.go +++ b/hrp/pkg/uixt/android_device.go @@ -81,15 +81,6 @@ func GetAndroidDeviceOptions(dev *AndroidDevice) (deviceOptions []AndroidDeviceO // uiautomator2 server must be started before // adb shell am instrument -w io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner func NewAndroidDevice(options ...AndroidDeviceOption) (device *AndroidDevice, err error) { - deviceList, err := DeviceList() - if err != nil { - return nil, errors.Wrap(code.AndroidDeviceConnectionError, - fmt.Sprintf("get attached devices failed: %v", err)) - } else if len(deviceList) == 0 { - return nil, errors.Wrap(code.AndroidDeviceConnectionError, - "not attached device found") - } - device = &AndroidDevice{ UIA2IP: UIA2ServerHost, UIA2Port: UIA2ServerPort, @@ -98,34 +89,56 @@ func NewAndroidDevice(options ...AndroidDeviceOption) (device *AndroidDevice, er option(device) } - serialNumber := device.SerialNumber - for _, dev := range deviceList { - // find device by serial number if specified - if serialNumber != "" && dev.Serial() != serialNumber { - continue - } - - device.SerialNumber = dev.Serial() - device.d = dev - device.logcat = NewAdbLogcat(device.SerialNumber) - return device, nil + deviceList, err := GetAndroidDevices(device.SerialNumber) + if err != nil { + return nil, errors.Wrap(code.AndroidDeviceConnectionError, err.Error()) } - return nil, errors.Wrap(code.AndroidDeviceConnectionError, - fmt.Sprintf("device %s not found", device.SerialNumber)) + dev := deviceList[0] + device.SerialNumber = dev.Serial() + device.d = dev + device.logcat = NewAdbLogcat(device.SerialNumber) + + log.Info().Str("serial", device.SerialNumber).Msg("select android device") + return device, nil } -func DeviceList() (devices []gadb.Device, err error) { +func GetAndroidDevices(serial ...string) (devices []*gadb.Device, err error) { var adbClient gadb.Client if adbClient, err = gadb.NewClientWith(AdbServerHost, AdbServerPort); err != nil { return nil, errors.Wrap(code.AndroidDeviceConnectionError, err.Error()) } - return adbClient.DeviceList() + if devices, err = adbClient.DeviceList(); err != nil { + return nil, errors.Wrap(code.AndroidDeviceConnectionError, + fmt.Sprintf("list android devices failed: %v", err)) + } + + var deviceList []*gadb.Device + // filter by serial + for _, d := range devices { + for _, s := range serial { + if s != "" && s != d.Serial() { + continue + } + deviceList = append(deviceList, d) + } + } + + if len(deviceList) == 0 { + var err error + if serial == nil || (len(serial) == 1 && serial[0] == "") { + err = fmt.Errorf("no android device found") + } else { + err = fmt.Errorf("no android device found for serial %v", serial) + } + return nil, err + } + return deviceList, nil } type AndroidDevice struct { - d gadb.Device + d *gadb.Device logcat *AdbLogcat SerialNumber string `json:"serial,omitempty" yaml:"serial,omitempty"` UIA2 bool `json:"uia2,omitempty" yaml:"uia2,omitempty"` // use uiautomator2 @@ -315,7 +328,7 @@ func (l *AdbLogcat) CatchLogcat() (err error) { } // clear logcat - if err = myexec.RunCommand("adb", "-s", l.serial, "logcat", "--clear"); err != nil { + if err = myexec.RunCommand("adb", "-s", l.serial, "shell", "logcat", "-c"); err != nil { return } diff --git a/hrp/pkg/uixt/android_test.go b/hrp/pkg/uixt/android_test.go index 44d8d3ac..b2a89037 100644 --- a/hrp/pkg/uixt/android_test.go +++ b/hrp/pkg/uixt/android_test.go @@ -324,7 +324,7 @@ func Test_getFreePort(t *testing.T) { } func TestDeviceList(t *testing.T) { - devices, err := DeviceList() + devices, err := GetAndroidDevices() if err != nil { t.Fatal(err) } @@ -353,6 +353,34 @@ func TestDriver_AppLaunch(t *testing.T) { t.Log(ioutil.WriteFile("s1.png", raw.Bytes(), 0o600)) } +func TestDriver_IsAppInForeground(t *testing.T) { + device, _ := NewAndroidDevice() + driver, err := device.NewDriver(nil) + if err != nil { + t.Fatal(err) + } + + err = driver.Driver.AppLaunch("com.android.settings") + if err != nil { + t.Fatal(err) + } + + yes, err := driver.Driver.IsAppInForeground(driver.Driver.GetLastLaunchedApp()) + if err != nil || !yes { + t.Fatal(err) + } + + _, err = driver.Driver.AppTerminate("com.android.settings") + if err != nil { + t.Fatal(err) + } + + yes, err = driver.Driver.IsAppInForeground("com.android.settings") + if err != nil || yes { + t.Fatal(err) + } +} + func TestDriver_KeepAlive(t *testing.T) { device, _ := NewAndroidDevice() driver, err := device.NewDriver(nil) diff --git a/hrp/pkg/uixt/client.go b/hrp/pkg/uixt/client.go index 5ebd9309..95a8b277 100644 --- a/hrp/pkg/uixt/client.go +++ b/hrp/pkg/uixt/client.go @@ -20,6 +20,8 @@ type Driver struct { urlPrefix *url.URL sessionId string client *http.Client + // cache the last launched package name + lastLaunchedPackageName string } func (wd *Driver) concatURL(u *url.URL, elem ...string) string { diff --git a/hrp/pkg/uixt/ext.go b/hrp/pkg/uixt/ext.go index ddc67bbe..f110e107 100644 --- a/hrp/pkg/uixt/ext.go +++ b/hrp/pkg/uixt/ext.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "image" + "image/gif" "image/jpeg" "image/png" "math/rand" @@ -284,6 +285,8 @@ func saveScreenShot(raw *bytes.Buffer, fileName string) (string, error) { err = png.Encode(file, img) case "jpeg": err = jpeg.Encode(file, img, nil) + case "gif": + err = gif.Encode(file, img, nil) default: return "", fmt.Errorf("unsupported image format: %s", format) } @@ -348,6 +351,19 @@ func (dExt *DriverExt) IsImageExist(text string) bool { var errActionNotImplemented = errors.New("UI action not implemented") +func convertToFloat64(val interface{}) (float64, error) { + switch v := val.(type) { + case float64: + return v, nil + case int: + return float64(v), nil + case int64: + return float64(v), nil + default: + return 0, fmt.Errorf("invalid type for conversion to float64: %T, value: %+v", val, val) + } +} + func (dExt *DriverExt) DoAction(action MobileAction) error { log.Info().Str("method", string(action.Method)).Interface("params", action.Params).Msg("start UI action") @@ -609,24 +625,54 @@ func (dExt *DriverExt) DoAction(action MobileAction) error { } return fmt.Errorf("invalid sleep params: %v(%T)", action.Params, action.Params) case CtlSleepRandom: - if params, ok := action.Params.([]interface{}); ok && len(params) == 2 { - var a, b float64 - if v, ok := params[0].(float64); ok { - a = v - } else if v, ok := params[0].(int64); ok { - a = float64(v) + params, ok := action.Params.([]interface{}) + if !ok { + return fmt.Errorf("invalid sleep random params: %v(%T)", action.Params, action.Params) + } + // append default weight 1 + if len(params) == 2 { + params = append(params, 1.0) + } + + var sections []struct { + min, max, weight float64 + } + totalProb := 0.0 + for i := 0; i+3 <= len(params); i += 3 { + min, err := convertToFloat64(params[i]) + if err != nil { + return errors.Wrapf(err, "invalid minimum time: %v", params[i]) } - if v, ok := params[1].(float64); ok { - b = v - } else if v, ok := params[1].(int64); ok { - b = float64(v) + max, err := convertToFloat64(params[i+1]) + if err != nil { + return errors.Wrapf(err, "invalid maximum time: %v", params[i+1]) } - n := a + rand.Float64()*(b-a) - log.Info().Float64("duration", n).Msg("sleep random seconds") - time.Sleep(time.Duration(n*1000) * time.Millisecond) + weight, err := convertToFloat64(params[i+2]) + if err != nil { + return errors.Wrapf(err, "invalid weight value: %v", params[i+2]) + } + totalProb += weight + sections = append(sections, + struct{ min, max, weight float64 }{min, max, weight}, + ) + } + + if totalProb == 0 { + log.Warn().Msg("total weight is 0, skip sleep") return nil } - return fmt.Errorf("invalid sleep random params: %v(%T)", action.Params, action.Params) + + r := rand.Float64() + accProb := 0.0 + for _, s := range sections { + accProb += s.weight / totalProb + if r < accProb { + n := s.min + rand.Float64()*(s.max-s.min) + log.Info().Float64("duration", n).Msg("sleep random seconds") + time.Sleep(time.Duration(n*1000) * time.Millisecond) + return nil + } + } case CtlScreenShot: // take snapshot log.Info().Msg("take snapshot for current screen") diff --git a/hrp/pkg/uixt/interface.go b/hrp/pkg/uixt/interface.go index d9706a17..501e6862 100644 --- a/hrp/pkg/uixt/interface.go +++ b/hrp/pkg/uixt/interface.go @@ -627,10 +627,14 @@ type WebDriver interface { // AppLaunch Launch an application with given bundle identifier in scope of current session. // !This method is only available since Xcode9 SDK - AppLaunch(bundleId string) error - // AppTerminate Terminate an application with the given bundle id. + AppLaunch(packageName string) error + // AppTerminate Terminate an application with the given pacakge name. // Either `true` if the app has been successfully terminated or `false` if it was not running - AppTerminate(bundleId string) (bool, error) + AppTerminate(packageName string) (bool, error) + // GetLastLaunchedApp returns the package name of the last launched app + GetLastLaunchedApp() string + // IsAppInForeground returns true if the given package is in foreground + IsAppInForeground(packageName string) (bool, error) // StartCamera Starts a new camera for recording StartCamera() error diff --git a/hrp/pkg/uixt/ios_device.go b/hrp/pkg/uixt/ios_device.go index c7fc3555..93828a8d 100644 --- a/hrp/pkg/uixt/ios_device.go +++ b/hrp/pkg/uixt/ios_device.go @@ -141,7 +141,7 @@ func WithIOSPcapOptions(options ...gidevice.PcapOption) IOSDeviceOption { } } -func IOSDevices(udid ...string) (devices []gidevice.Device, err error) { +func GetIOSDevices(udid ...string) (devices []gidevice.Device, err error) { var usbmux gidevice.Usbmux if usbmux, err = gidevice.NewUsbmux(); err != nil { return nil, errors.Wrap(code.IOSDeviceConnectionError, @@ -168,6 +168,15 @@ func IOSDevices(udid ...string) (devices []gidevice.Device, err error) { } } + if len(deviceList) == 0 { + var err error + if udid == nil || (len(udid) == 1 && udid[0] == "") { + err = fmt.Errorf("no ios device found") + } else { + err = fmt.Errorf("no ios device found for udid %v", udid) + } + return nil, err + } return deviceList, nil } @@ -223,31 +232,27 @@ func NewIOSDevice(options ...IOSDeviceOption) (device *IOSDevice, err error) { option(device) } - deviceList, err := IOSDevices(device.UDID) + deviceList, err := GetIOSDevices(device.UDID) if err != nil { - return nil, err + return nil, errors.Wrap(code.IOSDeviceConnectionError, err.Error()) } - for _, dev := range deviceList { - udid := dev.Properties().SerialNumber - device.UDID = udid - device.d = dev + dev := deviceList[0] + udid := dev.Properties().SerialNumber + device.UDID = udid + device.d = dev - // run xctest if XCTestBundleID is set - if device.XCTestBundleID != "" { - _, err = device.RunXCTest(device.XCTestBundleID) - if err != nil { - log.Error().Err(err).Str("udid", udid).Msg("failed to init XCTest") - continue - } + // run xctest if XCTestBundleID is set + if device.XCTestBundleID != "" { + _, err = device.RunXCTest(device.XCTestBundleID) + if err != nil { + log.Error().Err(err).Str("udid", udid).Msg("failed to init XCTest") + return } - - log.Info().Str("udid", device.UDID).Msg("select device") - return device, nil } - return nil, errors.Wrap(code.IOSDeviceConnectionError, - fmt.Sprintf("device %s not found", device.UDID)) + log.Info().Str("udid", device.UDID).Msg("select ios device") + return device, nil } type IOSDevice struct { diff --git a/hrp/pkg/uixt/ios_driver.go b/hrp/pkg/uixt/ios_driver.go index 53339d8c..33bbe0ed 100644 --- a/hrp/pkg/uixt/ios_driver.go +++ b/hrp/pkg/uixt/ios_driver.go @@ -308,6 +308,9 @@ func (wd *wdaDriver) AppLaunch(bundleId string) (err error) { data := make(map[string]interface{}) data["bundleId"] = bundleId _, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/apps/launch") + if err == nil { + wd.lastLaunchedPackageName = bundleId + } return } @@ -328,6 +331,9 @@ func (wd *wdaDriver) AppTerminate(bundleId string) (successful bool, err error) if successful, err = rawResp.valueConvertToBool(); err != nil { return false, err } + if wd.lastLaunchedPackageName == bundleId { + wd.lastLaunchedPackageName = "" // reset last launched package name + } return } @@ -348,6 +354,14 @@ func (wd *wdaDriver) AppDeactivate(second float64) (err error) { return } +func (wd *wdaDriver) GetLastLaunchedApp() (packageName string) { + return wd.lastLaunchedPackageName +} + +func (wd *wdaDriver) IsAppInForeground(packageName string) (bool, error) { + return false, errors.New("not implemented") +} + func (wd *wdaDriver) Tap(x, y int, options ...DataOption) error { return wd.TapFloat(float64(x), float64(y), options...) } diff --git a/hrp/pkg/uixt/ocr_vedem.go b/hrp/pkg/uixt/ocr_vedem.go index 03d8a271..f68964b6 100644 --- a/hrp/pkg/uixt/ocr_vedem.go +++ b/hrp/pkg/uixt/ocr_vedem.go @@ -93,16 +93,19 @@ func (s *veDEMOCRService) getOCRResult(imageBuf *bytes.Buffer) ([]OCRResult, err // retry 3 times for i := 1; i <= 3; i++ { resp, err = client.Do(req) - if err == nil { - break - } - var logID string if resp != nil { logID = getLogID(resp.Header) } + if err == nil && resp.StatusCode == http.StatusOK { + log.Debug(). + Str("X-TT-LOGID", logID). + Int("imageBufSize", size). + Msg("request OCR service success") + break + } log.Error().Err(err). - Str("logID", logID). + Str("X-TT-LOGID", logID). Int("imageBufSize", size). Msgf("request OCR service failed, retry %d", i) time.Sleep(1 * time.Second) diff --git a/hrp/step_mobile_ui.go b/hrp/step_mobile_ui.go index 16dc290b..99014a4c 100644 --- a/hrp/step_mobile_ui.go +++ b/hrp/step_mobile_ui.go @@ -281,10 +281,14 @@ func (s *StepMobile) Sleep(n float64) *StepMobile { return &StepMobile{step: s.step} } -func (s *StepMobile) SleepRandom(a, b float64) *StepMobile { +// SleepRandom specify random sleeping seconds after last action +// params have two different kinds: +// 1. [min, max] : min and max are float64 time range boudaries +// 2. [min1, max1, weight1, min2, max2, weight2, ...] : weight is the probability of the time range +func (s *StepMobile) SleepRandom(params ...float64) *StepMobile { s.mobileStep().Actions = append(s.mobileStep().Actions, uixt.MobileAction{ Method: uixt.CtlSleepRandom, - Params: []float64{a, b}, + Params: params, }) return &StepMobile{step: s.step} } @@ -557,6 +561,24 @@ func runStepMobileUI(s *SessionRunner, step *TStep) (stepResult *StepResult, err attachments := make(map[string]interface{}) if err != nil { attachments["error"] = err.Error() + + // check if app is in foreground + packageName := uiDriver.Driver.GetLastLaunchedApp() + yes, err2 := uiDriver.Driver.IsAppInForeground(packageName) + if packageName != "" && (!yes || err2 != nil) { + log.Error().Err(err2).Str("packageName", packageName).Msg("app is not in foreground") + err = errors.Wrap(code.MobileUIAppNotInForegroundError, err.Error()) + } + } + + // take screenshot after each step + screenshotPath, err := uiDriver.ScreenShot( + fmt.Sprintf("step_%d", time.Now().Unix())) + if err != nil { + log.Error().Err(err).Str("step", step.Name).Msg("take screenshot failed") + } else { + log.Info().Str("path", screenshotPath).Msg("take screenshot on step finished") + screenshots = append(screenshots, screenshotPath) } // save attachments @@ -595,16 +617,6 @@ func runStepMobileUI(s *SessionRunner, step *TStep) (stepResult *StepResult, err } } - // take snapshot - screenshotPath, err := uiDriver.ScreenShot( - fmt.Sprintf("validate_%d", time.Now().Unix())) - if err != nil { - log.Warn().Err(err).Str("step", step.Name).Msg("take screenshot failed") - } else { - log.Info().Str("path", screenshotPath).Msg("take screenshot before validation") - screenshots = append(screenshots, screenshotPath) - } - // validate validateResults, err := validateUI(uiDriver, step.Validators) if err != nil {