From 3b4367cac4c7d5069d05a5411dbcdd43d9cc84df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=99=E6=B3=93=E9=93=AE?= Date: Fri, 10 May 2024 07:02:41 +0000 Subject: [PATCH] Feat/yuhongzheng/pre auto install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: fix rotate tap swipe error * fix: default input frequency from 60 to 10 * fix: error getting window size during screen rotation * fix: kuake input unicode error * feat: android input by appium ime * feat: android swipe and tap with duration * fix: format import file * fix: format import file * feat: 新增按控件点击,获取设备应用,修改日志获取 * feat: 新增ui2控件点击 * fix: format file * fix: format file * Merge branch 'video-release' into 'feat/yuhongzheng/pre_auto_install' * merge * Merge branch 'feat/yuhongzheng/pre_auto_install' of… * fix: close reader * Merge branch 'video-release' into 'feat/yuhongzheng/pre_auto_install' * fix: format code * Merge branch 'feat/yuhongzheng/pre_auto_install' of… * fix: test send key https://code.byted.org/iesqa/httprunner/merge_requests/34 --- go.mod | 14 +- go.sum | 20 +++ hrp/internal/builtin/utils.go | 7 + hrp/internal/version/VERSION | 3 +- hrp/pkg/gadb/device.go | 66 +++++-- hrp/pkg/gadb/device_test.go | 50 +++++- hrp/pkg/uixt/action.go | 7 +- hrp/pkg/uixt/android_adb_driver.go | 255 +++++++++++++++++++++++++--- hrp/pkg/uixt/android_device.go | 89 ++++++---- hrp/pkg/uixt/android_layout.go | 62 +++++++ hrp/pkg/uixt/android_test.go | 89 ++++++++-- hrp/pkg/uixt/android_uia2_driver.go | 187 +++++++++++++++++--- hrp/pkg/uixt/interface.go | 11 ++ hrp/pkg/uixt/ios_driver.go | 20 ++- hrp/pkg/uixt/ios_test.go | 34 +++- hrp/pkg/uixt/swipe.go | 23 ++- hrp/pkg/uixt/tap.go | 31 +++- hrp/pkg/utf7/decoder.go | 149 ++++++++++++++++ hrp/pkg/utf7/encoder.go | 91 ++++++++++ hrp/pkg/utf7/utf7.go | 34 ++++ 20 files changed, 1108 insertions(+), 134 deletions(-) create mode 100644 hrp/pkg/uixt/android_layout.go create mode 100644 hrp/pkg/utf7/decoder.go create mode 100644 hrp/pkg/utf7/encoder.go create mode 100644 hrp/pkg/utf7/utf7.go diff --git a/go.mod b/go.mod index 23cc3089..1cc8d8c4 100644 --- a/go.mod +++ b/go.mod @@ -26,8 +26,9 @@ require ( github.com/spf13/cobra v1.5.0 github.com/stretchr/testify v1.8.4 gocv.io/x/gocv v0.32.1 - golang.org/x/net v0.14.0 + golang.org/x/net v0.20.0 golang.org/x/oauth2 v0.8.0 + golang.org/x/text v0.14.0 google.golang.org/grpc v1.57.0 google.golang.org/protobuf v1.31.0 gopkg.in/yaml.v3 v3.0.1 @@ -47,10 +48,12 @@ require ( github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/swag v0.22.3 // indirect github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/hashicorp/go-hclog v1.5.0 // indirect github.com/hashicorp/go-plugin v1.4.10 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect + github.com/incu6us/goimports-reviser/v2 v2.5.3 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -67,17 +70,20 @@ require ( github.com/prometheus/common v0.37.0 // indirect github.com/prometheus/procfs v0.8.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect - github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/tklauser/go-sysconf v0.3.10 // indirect github.com/tklauser/numcpus v0.5.0 // indirect github.com/yusufpapurcu/wmi v1.2.2 // indirect - golang.org/x/sys v0.11.0 // indirect - golang.org/x/text v0.12.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/tools v0.17.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230815205213-6bfd019c3878 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + mvdan.cc/gofumpt v0.6.0 // indirect ) // replace github.com/httprunner/funplugin => ../funplugin diff --git a/go.sum b/go.sum index 19e9da71..40bce52f 100644 --- a/go.sum +++ b/go.sum @@ -146,6 +146,8 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -178,6 +180,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/incu6us/goimports-reviser/v2 v2.5.3 h1:DzvFl1+qOIDukqN8vMM/10MQswFQywUdwXxsjuowxlc= +github.com/incu6us/goimports-reviser/v2 v2.5.3/go.mod h1:P18aXhQaED7izHIP9IPI9PqEs7Y7D9okq71Q8Y8yHN4= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE= github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg= @@ -290,6 +294,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= @@ -369,6 +375,8 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -402,6 +410,8 @@ golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= 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= @@ -420,6 +430,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -468,6 +480,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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= @@ -479,6 +493,8 @@ 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.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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= @@ -522,6 +538,8 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -638,6 +656,8 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +mvdan.cc/gofumpt v0.6.0 h1:G3QvahNDmpD+Aek/bNOLrFR2XC6ZAdo62dZu65gmwGo= +mvdan.cc/gofumpt v0.6.0/go.mod h1:4L0wf+kgIPZtcCWXynNS2e6bhmj73umwnuXSZarixzA= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/hrp/internal/builtin/utils.go b/hrp/internal/builtin/utils.go index 918530b3..9f1b24d5 100644 --- a/hrp/internal/builtin/utils.go +++ b/hrp/internal/builtin/utils.go @@ -483,3 +483,10 @@ func ConvertToStringSlice(val interface{}) ([]string, error) { } return nil, fmt.Errorf("invalid type for conversion to []string") } + +func GetCurrentDay() string { + now := time.Now() + // 格式化日期为 yyyyMMdd + formattedDate := now.Format("20060102") + return formattedDate +} diff --git a/hrp/internal/version/VERSION b/hrp/internal/version/VERSION index d1e4171f..42cdebb8 100644 --- a/hrp/internal/version/VERSION +++ b/hrp/internal/version/VERSION @@ -1 +1,2 @@ -v4.3.10 +v4.5.0 + diff --git a/hrp/pkg/gadb/device.go b/hrp/pkg/gadb/device.go index c9f61602..ead9227d 100644 --- a/hrp/pkg/gadb/device.go +++ b/hrp/pkg/gadb/device.go @@ -107,25 +107,38 @@ func (d *Device) features() (features Features, err error) { return features, nil } -func (d Device) HasAttribute(key string) bool { +func (d *Device) HasAttribute(key string) bool { _, ok := d.attrs[key] return ok } -func (d Device) Product() (string, error) { +func (d *Device) Product() (string, error) { if d.HasAttribute("product") { return d.attrs["product"], nil } return "", errors.New("does not have attribute: product") } -func (d Device) Model() (string, error) { +func (d *Device) Model() (string, error) { if d.HasAttribute("model") { return d.attrs["model"], nil } return "", errors.New("does not have attribute: model") } +func (d *Device) Brand() (string, error) { + if d.HasAttribute("brand") { + return d.attrs["brand"], nil + } + brand, err := d.RunShellCommand("getprop", "ro.product.brand") + brand = strings.TrimSpace(brand) + if err != nil { + return "", errors.New("does not have attribute: brand") + } + d.attrs["brand"] = brand + return brand, nil +} + func (d *Device) Usb() (string, error) { if d.HasAttribute("usb") { return d.attrs["usb"], nil @@ -133,7 +146,7 @@ func (d *Device) Usb() (string, error) { return "", errors.New("does not have attribute: usb") } -func (d Device) transportId() (string, error) { +func (d *Device) transportId() (string, error) { if d.HasAttribute("transport_id") { return d.attrs["transport_id"], nil } @@ -524,7 +537,7 @@ func (d *Device) Pull(remotePath string, dest io.Writer) (err error) { return } -func (d *Device) installViaABBExec(apk io.ReadSeeker) (raw []byte, err error) { +func (d *Device) installViaABBExec(apk io.ReadSeeker, args ...string) (raw []byte, err error) { var ( tp transport filesize int64 @@ -537,8 +550,11 @@ func (d *Device) installViaABBExec(apk io.ReadSeeker) (raw []byte, err error) { return nil, err } defer func() { _ = tp.Close() }() - - cmd := fmt.Sprintf("abb_exec:package\x00install\x00-t\x00-S\x00%d", filesize) + cmd := "abb_exec:package\x00install\x00-t" + for _, arg := range args { + cmd += "\x00" + arg + } + cmd += fmt.Sprintf("\x00-S\x00%d", filesize) if err = tp.SendWithCheck(cmd); err != nil { return nil, err } @@ -555,7 +571,7 @@ func (d *Device) installViaABBExec(apk io.ReadSeeker) (raw []byte, err error) { return } -func (d *Device) InstallAPK(apk io.ReadSeeker) (string, error) { +func (d *Device) InstallAPK(apk io.ReadSeeker, args ...string) (string, error) { haserr := func(ret string) bool { return strings.Contains(ret, "Failure") } @@ -575,8 +591,9 @@ func (d *Device) InstallAPK(apk io.ReadSeeker) (string, error) { if err != nil { return "", fmt.Errorf("error pushing: %v", err) } - - res, err := d.RunShellCommand("pm", "install", "-f", remote) + args = append([]string{"install"}, args...) + args = append(args, "-f", remote) + res, err := d.RunShellCommand("pm", args...) if err != nil { return "", errors.Wrap(err, "install apk failed") } @@ -591,7 +608,7 @@ func (d *Device) Uninstall(packageName string, keepData ...bool) (string, error) if len(keepData) == 0 { keepData = []bool{false} } - packageName = strings.ReplaceAll(packageName, " ", "") + packageName = strings.TrimSpace(packageName) if len(packageName) == 0 { return "", fmt.Errorf("invalid package name") } @@ -603,6 +620,33 @@ func (d *Device) Uninstall(packageName string, keepData ...bool) (string, error) return d.RunShellCommand("pm", args...) } +func (d *Device) ListPackages() ([]string, error) { + args := []string{"list", "packages"} + resRaw, err := d.RunShellCommand("pm", args...) + if err != nil { + return []string{}, err + } + lines := strings.Split(resRaw, "\n") + var packages []string + for _, line := range lines { + packageName := strings.TrimPrefix(line, "package:") + packages = append(packages, packageName) + } + return packages, nil +} + +func (d *Device) IsPackagesInstalled(packageName string) bool { + packages, err := d.ListPackages() + if err != nil { + return false + } + packageName = strings.TrimSpace(packageName) + if len(packageName) == 0 { + return false + } + return builtin.Contains(packages, packageName) +} + func (d *Device) ScreenCap() ([]byte, error) { if d.HasFeature(FeatShellV2) { return d.RunShellCommandV2WithBytes("screencap", "-p") diff --git a/hrp/pkg/gadb/device_test.go b/hrp/pkg/gadb/device_test.go index 50b37175..e1279051 100644 --- a/hrp/pkg/gadb/device_test.go +++ b/hrp/pkg/gadb/device_test.go @@ -60,7 +60,10 @@ func TestDevice_Product(t *testing.T) { for i := range devices { dev := devices[i] - product := dev.Product() + product, err := dev.Product() + if err != nil { + t.Fatal(err) + } t.Log(dev.Serial(), product) } } @@ -70,7 +73,24 @@ func TestDevice_Model(t *testing.T) { for i := range devices { dev := devices[i] - t.Log(dev.Serial(), dev.Model()) + model, err := dev.Model() + if err != nil { + t.Fatal(err) + } + t.Log(dev.Serial(), model) + } +} + +func TestDevice_Brand(t *testing.T) { + setupDevices(t) + + for i := range devices { + dev := devices[i] + brand, err := dev.Brand() + if err != nil { + t.Fatal(err) + } + t.Log(dev.Serial(), brand) } } @@ -79,7 +99,15 @@ func TestDevice_Usb(t *testing.T) { for i := range devices { dev := devices[i] - t.Log(dev.Serial(), dev.Usb(), dev.IsUsb()) + usb, err := dev.Usb() + if err != nil { + t.Fatal(err) + } + isUsb, err := dev.IsUsb() + if err != nil { + t.Fatal(err) + } + t.Log(dev.Serial(), usb, isUsb) } } @@ -315,6 +343,22 @@ func TestDevice_InstallAPK(t *testing.T) { } } +func TestDevice_ListPackages(t *testing.T) { + setupDevices(t) + for _, dev := range devices { + res, err := dev.ListPackages() + if err != nil { + t.Fatal(err) + } + t.Log(res) + installed := dev.IsPackagesInstalled("io.appium.uiautomator2.server") + if err != nil { + t.Fatal(err) + } + t.Log(installed) + } +} + func TestDevice_HasFeature(t *testing.T) { setupDevices(t) diff --git a/hrp/pkg/uixt/action.go b/hrp/pkg/uixt/action.go index c15b4406..6ca6bcce 100644 --- a/hrp/pkg/uixt/action.go +++ b/hrp/pkg/uixt/action.go @@ -297,7 +297,7 @@ func (o *ActionOptions) updateData(data map[string]interface{}) { data["frequency"] = o.Frequency } if _, ok := data["frequency"]; !ok { - data["frequency"] = 60 // default frequency + data["frequency"] = 10 // default frequency } if _, ok := data["replace"]; !ok { @@ -320,6 +320,11 @@ func NewActionOptions(options ...ActionOption) *ActionOptions { return actionOptions } +type TapTextAction struct { + Text string + Options []ActionOption +} + type ActionOption func(o *ActionOptions) func WithCustomOption(key string, value interface{}) ActionOption { diff --git a/hrp/pkg/uixt/android_adb_driver.go b/hrp/pkg/uixt/android_adb_driver.go index 2d552a36..c642a245 100644 --- a/hrp/pkg/uixt/android_adb_driver.go +++ b/hrp/pkg/uixt/android_adb_driver.go @@ -1,25 +1,32 @@ package uixt import ( + "bufio" "bytes" + "encoding/xml" "fmt" "io/fs" - "io/ioutil" + "os" "path/filepath" + "regexp" "strconv" "strings" "time" + "github.com/httprunner/funplugin/myexec" "github.com/pkg/errors" "github.com/rs/zerolog/log" - "github.com/httprunner/funplugin/myexec" "github.com/httprunner/httprunner/v4/hrp/internal/code" "github.com/httprunner/httprunner/v4/hrp/internal/env" "github.com/httprunner/httprunner/v4/hrp/pkg/gadb" + "github.com/httprunner/httprunner/v4/hrp/pkg/utf7" ) -const AdbKeyBoardPackageName = "com.android.adbkeyboard/.AdbIME" +const ( + AdbKeyBoardPackageName = "com.android.adbkeyboard/.AdbIME" + UnicodeImePackageName = "io.appium.settings/.UnicodeIME" +) type adbDriver struct { Driver @@ -91,7 +98,14 @@ func (ad *adbDriver) WindowSize() (size Size, err error) { return Size{Width: width, Height: height}, nil } } - + orientation, err := ad.Orientation() + if err != nil { + log.Warn().Err(err).Msgf("window size get orientation failed, use default orientation") + orientation = OrientationPortrait + } + if orientation != OrientationPortrait { + size.Width, size.Height = size.Height, size.Width + } err = errors.New("physical window size not found by adb") return } @@ -185,6 +199,24 @@ func (ad *adbDriver) StopCamera() (err error) { return } +func (ad *adbDriver) Orientation() (orientation Orientation, err error) { + output, err := ad.adbClient.RunShellCommand("dumpsys", "input", "|", "grep", "'SurfaceOrientation'") + if err != nil { + return + } + re := regexp.MustCompile(`SurfaceOrientation: (\d)`) + matches := re.FindStringSubmatch(output) + if len(matches) > 1 { // 确保找到了匹配项 + if matches[1] == "0" || matches[1] == "2" { + return OrientationPortrait, nil + } else if matches[1] == "1" || matches[1] == "3" { + return OrientationLandscapeLeft, nil + } + } + err = fmt.Errorf("not found SurfaceOrientation value") + return +} + func (ad *adbDriver) Homescreen() (err error) { return ad.PressKeyCode(KCHome, KMEmpty) } @@ -326,6 +358,15 @@ func (ad *adbDriver) GetPasteboard(contentType PasteboardType) (raw *bytes.Buffe } func (ad *adbDriver) SendKeys(text string, options ...ActionOption) (err error) { + err = ad.SendUnicodeKeys(text, options...) + if err == nil { + return + } + err = ad.InputText(text, options...) + return +} + +func (ad *adbDriver) InputText(text string, options ...ActionOption) (err error) { // adb shell input text _, err = ad.adbClient.RunShellCommand("input", "text", text) if err != nil { @@ -334,6 +375,36 @@ func (ad *adbDriver) SendKeys(text string, options ...ActionOption) (err error) return nil } +func (ad *adbDriver) SendUnicodeKeys(text string, options ...ActionOption) (err error) { + // If the Unicode IME is not installed, fall back to the old interface. + // There might be differences in the tracking schemes across different phones, and it is pending further verification. + // In release version: without the Unicode IME installed, the test cannot execute. + if !ad.IsUnicodeIMEInstalled() { + return fmt.Errorf("appium unicode ime not installed") + } + currentIme, err := ad.GetIme() + if err != nil { + return + } + if currentIme != UnicodeImePackageName { + defer func() { + _ = ad.SetIme(currentIme) + }() + err = ad.SetIme(UnicodeImePackageName) + if err != nil { + log.Warn().Err(err).Msgf("set Unicode Ime failed") + return + } + } + encodedStr, err := utf7.Encoding.NewEncoder().String(text) + if err != nil { + log.Warn().Err(err).Msgf("encode text with modified utf7 failed") + return + } + err = ad.InputText("\""+strings.ReplaceAll(encodedStr, "\"", "\\\"")+"\"", options...) + return +} + func (ad *adbDriver) IsAdbKeyBoardInstalled() bool { output, err := ad.adbClient.RunShellCommand("ime", "list", "-a") if err != nil { @@ -342,6 +413,14 @@ func (ad *adbDriver) IsAdbKeyBoardInstalled() bool { return strings.Contains(output, AdbKeyBoardPackageName) } +func (ad *adbDriver) IsUnicodeIMEInstalled() bool { + output, err := ad.adbClient.RunShellCommand("ime", "list", "-s") + if err != nil { + return false + } + return strings.Contains(output, UnicodeImePackageName) +} + func (ad *adbDriver) SendKeysByAdbKeyBoard(text string) (err error) { defer func() { // Reset to default, don't care which keyboard was chosen before switch: @@ -404,10 +483,103 @@ func (ad *adbDriver) Screenshot() (raw *bytes.Buffer, err error) { } func (ad *adbDriver) Source(srcOpt ...SourceOption) (source string, err error) { - err = errDriverNotImplemented + _, err = ad.adbClient.RunShellCommand("rm", "-rf", "/sdcard/window_dump.xml") + if err != nil { + return + } + // 高版本报错 ERROR: null root node returned by UiTestAutomationBridge. + _, err = ad.adbClient.RunShellCommand("uiautomator", "dump") + if err != nil { + return + } + source, err = ad.adbClient.RunShellCommand("cat", "/sdcard/window_dump.xml") + if err != nil { + return + } return } +func (ad *adbDriver) sourceTree(srcOpt ...SourceOption) (sourceTree *Hierarchy, err error) { + source, err := ad.Source() + if err != nil { + return + } + sourceTree = new(Hierarchy) + err = xml.Unmarshal([]byte(source), sourceTree) + if err != nil { + return + } + return +} + +func (ad *adbDriver) TapByText(text string, options ...ActionOption) error { + sourceTree, err := ad.sourceTree() + if err != nil { + return err + } + return ad.tapByTextUsingHierarchy(sourceTree, text, options...) +} + +func (ad *adbDriver) tapByTextUsingHierarchy(hierarchy *Hierarchy, text string, options ...ActionOption) error { + bounds := ad.searchNodes(hierarchy.Layout, text, options...) + actionOptions := NewActionOptions(options...) + if len(bounds) == 0 { + if actionOptions.IgnoreNotFoundError { + log.Info().Msg("not found element by text " + text) + return nil + } + return errors.New("not found element by text " + text) + } + for _, bound := range bounds { + width, height := bound.Center() + err := ad.TapFloat(width, height, options...) + if err != nil { + return err + } + } + return nil +} + +func (ad *adbDriver) TapByTexts(actions ...TapTextAction) error { + sourceTree, err := ad.sourceTree() + if err != nil { + return err + } + + for _, action := range actions { + err := ad.tapByTextUsingHierarchy(sourceTree, action.Text, action.Options...) + if err != nil { + return err + } + } + return nil +} + +func (ad *adbDriver) searchNodes(nodes []Layout, text string, options ...ActionOption) []Bounds { + actionOptions := NewActionOptions(options...) + var results []Bounds + for _, node := range nodes { + result := ad.searchNodes(node.Layout, text, options...) + results = append(results, result...) + if actionOptions.Regex { + // regex on, check if match regex + if !regexp.MustCompile(text).MatchString(node.Text) { + continue + } + } else { + // regex off, check if match exactly + if node.Text != text { + ad.searchNodes(node.Layout, text, options...) + continue + } + } + if node.Bounds != nil { + results = append(results, *node.Bounds) + } + } + return results +} + func (ad *adbDriver) AccessibleSource() (source string, err error) { err = errDriverNotImplemented return @@ -435,14 +607,8 @@ func (ad *adbDriver) IsHealthy() (healthy bool, err error) { func (ad *adbDriver) StartCaptureLog(identifier ...string) (err error) { log.Info().Msg("start adb log recording") - - // clear logcat - if _, err = ad.adbClient.RunShellCommand("logcat", "-c"); err != nil { - return err - } - // start logcat - err = ad.logcat.CatchLogcat() + err = ad.logcat.CatchLogcat("iesqaMonitor:V") if err != nil { err = errors.Wrap(code.AndroidCaptureLogError, fmt.Sprintf("start adb log recording failed: %v", err)) @@ -452,17 +618,18 @@ func (ad *adbDriver) StartCaptureLog(identifier ...string) (err error) { } func (ad *adbDriver) StopCaptureLog() (result interface{}, err error) { - log.Info().Msg("stop adb log recording") - err = ad.logcat.Stop() + defer func() { + log.Info().Msg("stop adb log recording") + err = ad.logcat.Stop() + if err != nil { + log.Error().Err(err).Msg("failed to get adb log recording") + } + }() if err != nil { - log.Error().Err(err).Msg("failed to get adb log recording") - err = errors.Wrap(code.AndroidCaptureLogError, - fmt.Sprintf("get adb log recording failed: %v", err)) - return "", err + log.Error().Err(err).Msg("failed to close adb log writer") } - content := ad.logcat.logBuffer.String() - log.Info().Str("logcat content", content).Msg("display logcat content") - pointRes := ConvertPoints(content) + pointRes := ConvertPoints(ad.logcat.logs) + // 没有解析到打点日志,走兜底逻辑 if len(pointRes) == 0 { log.Info().Msg("action log is null, use action file >>>") @@ -476,7 +643,6 @@ func (ad *adbDriver) StopCaptureLog() (result interface{}, err error) { } return nil }) - // 先保持原有状态码不变,这里不return error if err != nil { log.Error().Err(err).Msg("read log file fail") @@ -488,13 +654,28 @@ func (ad *adbDriver) StopCaptureLog() (result interface{}, err error) { return pointRes, nil } - data, err := ioutil.ReadFile(files[0]) + reader, err := os.Open(files[0]) if err != nil { - log.Info().Msg("read File error") + log.Info().Msg("open File error") + return pointRes, nil + } + defer func() { + _ = reader.Close() + }() + + var lines []string // 创建一个空的字符串数组来存储文件的每一行 + + // 使用 bufio.NewScanner 读取文件 + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + lines = append(lines, scanner.Text()) // 将每行文本添加到字符串数组 + } + + if err := scanner.Err(); err != nil { return pointRes, nil } - pointRes = ConvertPoints(string(data)) + pointRes = ConvertPoints(lines) } return pointRes, nil } @@ -534,6 +715,30 @@ func (ad *adbDriver) GetForegroundApp() (app AppInfo, err error) { return AppInfo{}, errors.Wrap(code.MobileUIAssertForegroundAppError, "get foreground app failed") } +func (ad *adbDriver) SetIme(ime string) error { + _, err := ad.adbClient.RunShellCommand("ime", "set", ime) + if err != nil { + return err + } + // even if the shell command has returned, + // as there might be a situation where the input method has not been completely switched yet + // Listen to the following message. + // InputMethodManagerService: onServiceConnected, name:ComponentInfo{io.appium.settings/io.appium.settings.UnicodeIME}, token:android.os.Binder@44f825 + // But there is no such log on Vivo. + time.Sleep(3 * time.Second) + return nil +} + +func (ad *adbDriver) GetIme() (ime string, err error) { + currentIme, err := ad.adbClient.RunShellCommand("settings", "get", "secure", "default_input_method") + if err != nil { + log.Warn().Err(err).Msgf("get default ime failed") + return + } + currentIme = strings.TrimSpace(currentIme) + return currentIme, nil +} + func (ad *adbDriver) AssertForegroundApp(packageName string, activityType ...string) error { log.Debug().Str("package_name", packageName). Strs("activity_type", activityType). diff --git a/hrp/pkg/uixt/android_device.go b/hrp/pkg/uixt/android_device.go index 84edf057..34764a70 100644 --- a/hrp/pkg/uixt/android_device.go +++ b/hrp/pkg/uixt/android_device.go @@ -1,6 +1,7 @@ package uixt import ( + "bufio" "bytes" "context" "fmt" @@ -22,7 +23,6 @@ var ( AdbServerPort = gadb.AdbServerPort // 5037 UIA2ServerHost = "localhost" UIA2ServerPort = 6790 - DeviceTempPath = "/data/local/tmp" ) const forwardToPrefix = "forward-to-" @@ -276,27 +276,43 @@ func getFreePort() (int, error) { return l.Addr().(*net.TCPAddr).Port, nil } +type LineCallback func(string) + type AdbLogcat struct { - serial string - logBuffer *bytes.Buffer - errs []error - stopping chan struct{} - done chan struct{} - cmd *exec.Cmd + serial string + // logBuffer *bytes.Buffer + errs []error + stopping chan struct{} + done chan struct{} + cmd *exec.Cmd + callback LineCallback + logs []string +} + +func NewAdbLogcatWithCallback(serial string, callback LineCallback) *AdbLogcat { + return &AdbLogcat{ + serial: serial, + // logBuffer: new(bytes.Buffer), + stopping: make(chan struct{}), + done: make(chan struct{}), + callback: callback, + logs: make([]string, 0), + } } func NewAdbLogcat(serial string) *AdbLogcat { return &AdbLogcat{ - serial: serial, - logBuffer: new(bytes.Buffer), - stopping: make(chan struct{}), - done: make(chan struct{}), + serial: serial, + // logBuffer: new(bytes.Buffer), + stopping: make(chan struct{}), + done: make(chan struct{}), + logs: make([]string, 0), } } // CatchLogcatContext starts logcat with timeout context func (l *AdbLogcat) CatchLogcatContext(timeoutCtx context.Context) (err error) { - if err = l.CatchLogcat(); err != nil { + if err = l.CatchLogcat(""); err != nil { return } go func() { @@ -331,7 +347,7 @@ func (l *AdbLogcat) Errors() (err error) { return } -func (l *AdbLogcat) CatchLogcat() (err error) { +func (l *AdbLogcat) CatchLogcat(filter string) (err error) { if l.cmd != nil { log.Warn().Msg("logcat already start") return nil @@ -341,33 +357,43 @@ func (l *AdbLogcat) CatchLogcat() (err error) { if err = myexec.RunCommand("adb", "-s", l.serial, "shell", "logcat", "-c"); err != nil { return } - + args := []string{"-s", l.serial, "logcat", "--format", "time"} + if filter != "" { + args = append(args, "-s", filter) + } // start logcat - l.cmd = myexec.Command("adb", "-s", l.serial, - "logcat", "--format", "time", "-s", "iesqaMonitor:V") - l.cmd.Stderr = l.logBuffer - l.cmd.Stdout = l.logBuffer + l.cmd = myexec.Command("adb", args...) + // l.cmd.Stderr = l.logBuffer + // l.cmd.Stdout = l.logBuffer + reader, err := l.cmd.StdoutPipe() + if err != nil { + return err + } if err = l.cmd.Start(); err != nil { return } + go func() { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + if l.callback != nil { + l.callback(line) // Process each line with callback + } else { + l.logs = append(l.logs, line) // Store line if no callback + } + } + }() go func() { <-l.stopping + if e := reader.Close(); e != nil { + log.Error().Err(e).Msg("close logcat reader failed") + } if e := myexec.KillProcessesByGpid(l.cmd); e != nil { log.Error().Err(e).Msg("kill logcat process failed") } l.done <- struct{}{} }() - return -} -func (l *AdbLogcat) BufferedLogcat() (err error) { - // -d: dump the current buffered logcat result and exits - cmd := myexec.Command("adb", "-s", l.serial, "logcat", "-d") - cmd.Stdout = l.logBuffer - cmd.Stderr = l.logBuffer - if err = cmd.Run(); err != nil { - return - } return } @@ -381,8 +407,8 @@ type ExportPoint struct { RunTime int `json:"run_time,omitempty" yaml:"run_time,omitempty"` } -func ConvertPoints(data string) (eps []ExportPoint) { - lines := strings.Split(data, "\n") +func ConvertPoints(lines []string) (eps []ExportPoint) { + log.Info().Msg("ConvertPoints") for _, line := range lines { if strings.Contains(line, "ext") { idx := strings.Index(line, "{") @@ -396,6 +422,7 @@ func ConvertPoints(data string) (eps []ExportPoint) { log.Error().Msg("failed to parse point data") continue } + log.Info().Msg(line) eps = append(eps, p) } } @@ -562,7 +589,7 @@ func (s UiSelectorHelper) Index(index int) UiSelectorHelper { // // For example, to simulate a user click on // the third image that is enabled in a UI screen, you -// could specify a a search criteria where the instance is +// could specify a search criteria where the instance is // 2, the `className(String)` matches the image // widget class, and `enabled(boolean)` is true. // The code would look like this: diff --git a/hrp/pkg/uixt/android_layout.go b/hrp/pkg/uixt/android_layout.go new file mode 100644 index 00000000..b036ee72 --- /dev/null +++ b/hrp/pkg/uixt/android_layout.go @@ -0,0 +1,62 @@ +package uixt + +import ( + "encoding/xml" + "fmt" + "regexp" + "strconv" +) + +type Attributes struct { + Index int `xml:"index,attr"` + Package string `xml:"package,attr"` + Class string `xml:"class,attr"` + Text string `xml:"text,attr"` + ResourceId string `xml:"resource-id,attr"` + Checkable bool `xml:"checkable,attr"` + Checked bool `xml:"checked,attr"` + Clickable bool `xml:"clickable,attr"` + Enabled bool `xml:"enabled,attr"` + Focusable bool `xml:"focusable,attr"` + Focused bool `xml:"focused,attr"` + LongClickable bool `xml:"long-clickable,attr"` + Password bool `xml:"password,attr"` + Scrollable bool `xml:"scrollable,attr"` + Selected bool `xml:"selected,attr"` + Bounds *Bounds `xml:"bounds,attr"` + Displayed bool `xml:"displayed,attr"` +} + +type Hierarchy struct { + XMLName xml.Name `xml:"hierarchy"` + Attributes + Layout []Layout `xml:",any"` +} + +type Layout struct { + Attributes + Layout []Layout `xml:",any"` +} + +type Bounds struct { + X1, Y1, X2, Y2 int +} + +func (b *Bounds) Center() (float64, float64) { + return float64(b.X1+b.X2) / 2, float64(b.Y1+b.Y2) / 2 +} + +func (b *Bounds) UnmarshalXMLAttr(attr xml.Attr) error { + // 正则表达式用于解析格式为"[x1,y1][x2,y2]" + re := regexp.MustCompile(`\[(\d+),(\d+)]\[(\d+),(\d+)]`) + matches := re.FindStringSubmatch(attr.Value) + if matches == nil { + return fmt.Errorf("bounds format is incorrect") + } + // 转换字符串为整数 + b.X1, _ = strconv.Atoi(matches[1]) + b.Y1, _ = strconv.Atoi(matches[2]) + b.X2, _ = strconv.Atoi(matches[3]) + b.Y2, _ = strconv.Atoi(matches[4]) + return nil +} diff --git a/hrp/pkg/uixt/android_test.go b/hrp/pkg/uixt/android_test.go index ffed7d4e..6284fb66 100644 --- a/hrp/pkg/uixt/android_test.go +++ b/hrp/pkg/uixt/android_test.go @@ -6,18 +6,21 @@ import ( "encoding/json" "fmt" "io/ioutil" + "os" + "strings" "testing" "time" ) var ( - uiaServerURL = "http://localhost:6790/wd/hub" + uiaServerURL = "http://forward-to-6790:6790/wd/hub" driverExt *DriverExt ) func setupAndroid(t *testing.T) { device, err := NewAndroidDevice() checkErr(t, err) + device.UIA2 = false driverExt, err = device.NewDriver() checkErr(t, err) } @@ -132,6 +135,18 @@ func TestDriver_Source(t *testing.T) { t.Log(source) } +func TestDriver_TapByText(t *testing.T) { + driver, err := NewUIADriver(nil, uiaServerURL) + if err != nil { + t.Fatal(err) + } + + err = driver.TapByText("安装") + if err != nil { + t.Fatal(err) + } +} + func TestDriver_BatteryInfo(t *testing.T) { driver, err := NewUIADriver(nil, uiaServerURL) if err != nil { @@ -204,7 +219,7 @@ func TestDriver_Swipe(t *testing.T) { t.Fatal(err) } - err = driver.Swipe(400, 1000, 400, 500) + err = driver.Swipe(400, 1000, 400, 500, WithPressDuration(2000)) if err != nil { t.Fatal(err) } @@ -215,6 +230,14 @@ func TestDriver_Swipe(t *testing.T) { } } +func TestDriver_Swipe_Relative(t *testing.T) { + setupAndroid(t) + err := driverExt.SwipeRelative(0.5, 0.7, 0.5, 0.5) + if err != nil { + t.Fatal(err) + } +} + func TestDriver_Drag(t *testing.T) { driver, err := NewUIADriver(nil, uiaServerURL) if err != nil { @@ -235,28 +258,26 @@ func TestDriver_Drag(t *testing.T) { } func TestDriver_SendKeys(t *testing.T) { - driver, err := NewUIADriver(nil, uiaServerURL) + setupAndroid(t) + + err := driverExt.Driver.SendKeys("Android\"输入速度测试", WithIdentifier("test")) if err != nil { t.Fatal(err) } - err = driver.SendKeys("abc") - if err != nil { - t.Fatal(err) - } time.Sleep(time.Second * 2) - err = driver.SendKeys("def") - if err != nil { - t.Fatal(err) - } - time.Sleep(time.Second * 2) + //err = driver.SendKeys("def") + //if err != nil { + // t.Fatal(err) + //} + //time.Sleep(time.Second * 2) - err = driver.SendKeys("\\n") + //err = driver.SendKeys("\\n") // err = driver.SendKeys(`\n`, false) - if err != nil { - t.Fatal(err) - } + //if err != nil { + // t.Fatal(err) + //} } func TestDriver_PressBack(t *testing.T) { @@ -421,10 +442,44 @@ func TestDriver_AppTerminate(t *testing.T) { func TestConvertPoints(t *testing.T) { data := "10-09 20:16:48.216 I/iesqaMonitor(17845): {\"duration\":0,\"end\":1665317808206,\"ext\":\"输入\",\"from\":{\"x\":0.0,\"y\":0.0},\"operation\":\"Gtf-SendKeys\",\"run_time\":627,\"start\":1665317807579,\"start_first\":0,\"start_last\":0,\"to\":{\"x\":0.0,\"y\":0.0}}\n10-09 20:18:22.899 I/iesqaMonitor(17845): {\"duration\":0,\"end\":1665317902898,\"ext\":\"进入直播间\",\"from\":{\"x\":717.0,\"y\":2117.5},\"operation\":\"Gtf-Tap\",\"run_time\":121,\"start\":1665317902777,\"start_first\":0,\"start_last\":0,\"to\":{\"x\":717.0,\"y\":2117.5}}\n10-09 20:18:32.063 I/iesqaMonitor(17845): {\"duration\":0,\"end\":1665317912062,\"ext\":\"第一次上划\",\"from\":{\"x\":1437.0,\"y\":2409.9},\"operation\":\"Gtf-Swipe\",\"run_time\":32,\"start\":1665317912030,\"start_first\":0,\"start_last\":0,\"to\":{\"x\":1437.0,\"y\":2409.9}}" - eps := ConvertPoints(data) + + eps := ConvertPoints(strings.Split(data, "\n")) if len(eps) != 3 { t.Fatal() } jsons, _ := json.Marshal(eps) println(fmt.Sprintf("%v", string(jsons))) } + +func TestDriver_ShellInputUnicode(t *testing.T) { + device, _ := NewAndroidDevice() + driver, err := device.NewAdbDriver() + if err != nil { + t.Fatal(err) + } + + err = driver.SendKeys("test中文输入&") + if err != nil { + t.Fatal(err) + } + + raw, err := driver.Screenshot() + if err != nil { + t.Fatal(err) + } + + t.Log(os.WriteFile("s1.png", raw.Bytes(), 0o600)) +} + +func TestTapTexts(t *testing.T) { + setupAndroid(t) + actions := []TapTextAction{ + {Text: "^.*无视风险安装$", Options: []ActionOption{WithTapOffset(100, 0), WithRegex(true), WithIgnoreNotFoundError(true)}}, + {Text: "已了解此应用未经检测.*", Options: []ActionOption{WithTapOffset(-450, 0), WithRegex(true), WithIgnoreNotFoundError(true)}}, + {Text: "^(.*无视风险安装|确定|继续|完成|点击继续安装|继续安装旧版本|替换|安装|授权本次安装|继续安装|重新安装)$", Options: []ActionOption{WithRegex(true), WithIgnoreNotFoundError(true)}}, + } + err := driverExt.Driver.TapByTexts(actions...) + if err != nil { + t.Fatal(err) + } +} diff --git a/hrp/pkg/uixt/android_uia2_driver.go b/hrp/pkg/uixt/android_uia2_driver.go index 5f38556d..acc58b35 100644 --- a/hrp/pkg/uixt/android_uia2_driver.go +++ b/hrp/pkg/uixt/android_uia2_driver.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/base64" "encoding/json" + "encoding/xml" "fmt" "net" "net/http" @@ -16,6 +17,7 @@ import ( "github.com/rs/zerolog/log" "github.com/httprunner/httprunner/v4/hrp/internal/code" + "github.com/httprunner/httprunner/v4/hrp/pkg/utf7" ) var errDriverNotImplemented = errors.New("driver method not implemented") @@ -224,6 +226,14 @@ func (ud *uiaDriver) WindowSize() (size Size, err error) { return Size{}, err } size = reply.Value.Size + orientation, err := ud.Orientation() + if err != nil { + log.Warn().Err(err).Msgf("window size get orientation failed, use default orientation") + orientation = OrientationPortrait + } + if orientation != OrientationPortrait { + size.Width, size.Height = size.Height, size.Width + } return } @@ -253,6 +263,20 @@ func (ud *uiaDriver) PressKeyCode(keyCode KeyCode, metaState KeyMeta, flags ...K return } +func (ud *uiaDriver) Orientation() (orientation Orientation, err error) { + // [[FBRoute GET:@"/orientation"] respondWithTarget:self action:@selector(handleGetOrientation:)] + var rawResp rawResponse + if rawResp, err = ud.httpGET("/session", ud.sessionId, "/orientation"); err != nil { + return "", err + } + reply := new(struct{ Value Orientation }) + if err = json.Unmarshal(rawResp, reply); err != nil { + return "", err + } + orientation = reply.Value + return +} + func (ud *uiaDriver) Tap(x, y int, options ...ActionOption) error { return ud.TapFloat(float64(x), float64(y), options...) } @@ -268,15 +292,31 @@ func (ud *uiaDriver) TapFloat(x, y float64, options ...ActionOption) (err error) x += actionOptions.getRandomOffset() y += actionOptions.getRandomOffset() - data := map[string]interface{}{ - "x": x, - "y": y, + duration := 100.0 + if actionOptions.PressDuration > 0 { + duration = actionOptions.PressDuration } + data := map[string]interface{}{ + "actions": []interface{}{ + map[string]interface{}{ + "type": "pointer", + "parameters": map[string]string{"pointerType": "touch"}, + "id": "touch", + "actions": []interface{}{ + map[string]interface{}{"type": "pointerMove", "duration": 0, "x": x, "y": y, "origin": "viewport"}, + map[string]interface{}{"type": "pointerDown", "duration": 0, "button": 0}, + map[string]interface{}{"type": "pause", "duration": duration}, + map[string]interface{}{"type": "pointerUp", "duration": 0, "button": 0}, + }, + }, + }, + } + // update data options in post data for extra uiautomator configurations actionOptions.updateData(data) - _, err = ud.httpPOST(data, "/session", ud.sessionId, "appium/tap") - return + _, err = ud.httpPOST(data, "/session", ud.sessionId, "actions/tap") + return err } func (ud *uiaDriver) TouchAndHold(x, y int, second ...float64) (err error) { @@ -358,17 +398,30 @@ func (ud *uiaDriver) SwipeFloat(fromX, fromY, toX, toY float64, options ...Actio toX += actionOptions.getRandomOffset() toY += actionOptions.getRandomOffset() + duration := 200.0 + if actionOptions.PressDuration > 0 { + duration = actionOptions.PressDuration + } data := map[string]interface{}{ - "startX": fromX, - "startY": fromY, - "endX": toX, - "endY": toY, + "actions": []interface{}{ + map[string]interface{}{ + "type": "pointer", + "parameters": map[string]string{"pointerType": "touch"}, + "id": "touch", + "actions": []interface{}{ + map[string]interface{}{"type": "pointerMove", "duration": 0, "x": fromX, "y": fromY, "origin": "viewport"}, + map[string]interface{}{"type": "pointerDown", "duration": 0, "button": 0}, + map[string]interface{}{"type": "pointerMove", "duration": duration, "x": toX, "y": toY, "origin": "viewport"}, + map[string]interface{}{"type": "pointerUp", "duration": 0, "button": 0}, + }, + }, + }, } // update data options in post data for extra uiautomator configurations actionOptions.updateData(data) - _, err := ud.httpPOST(data, "/session", ud.sessionId, "touch/perform") + _, err := ud.httpPOST(data, "/session", ud.sessionId, "actions/swipe") return err } @@ -415,25 +468,79 @@ func (ud *uiaDriver) GetPasteboard(contentType PasteboardType) (raw *bytes.Buffe return } +// SendKeys Android input does not support setting frequency. func (ud *uiaDriver) SendKeys(text string, options ...ActionOption) (err error) { // register(postHandler, new SendKeysToElement("/wd/hub/session/:sessionId/keys")) // https://github.com/appium/appium-uiautomator2-server/blob/master/app/src/main/java/io/appium/uiautomator2/handler/SendKeysToElement.java#L76-L85 actionOptions := NewActionOptions(options...) - data := map[string]interface{}{ - "text": text, - } - // new data options in post data for extra uiautomator configurations - actionOptions.updateData(data) - - _, err = ud.httpPOST(data, "/session", ud.sessionId, "keys") + err = ud.SendUnicodeKeys(text, options...) if err != nil { - // use com.android.adbkeyboard if existed - if ud.IsAdbKeyBoardInstalled() { - err = ud.SendKeysByAdbKeyBoard(text) - } else { - _, err = ud.adbClient.RunShellCommand("input", "text", text) + data := map[string]interface{}{ + "text": text, + } + + // new data options in post data for extra uiautomator configurations + actionOptions.updateData(data) + + _, err = ud.httpPOST(data, "/session", ud.sessionId, "/keys") + } + return +} + +func (ud *uiaDriver) SendUnicodeKeys(text string, options ...ActionOption) (err error) { + // If the Unicode IME is not installed, fall back to the old interface. + // There might be differences in the tracking schemes across different phones, and it is pending further verification. + // In release version: without the Unicode IME installed, the test cannot execute. + if !ud.IsUnicodeIMEInstalled() { + return fmt.Errorf("appium unicode ime not installed") + } + currentIme, err := ud.adbDriver.GetIme() + if err != nil { + return + } + if currentIme != UnicodeImePackageName { + defer func() { + _ = ud.adbDriver.SetIme(currentIme) + }() + err = ud.adbDriver.SetIme(UnicodeImePackageName) + if err != nil { + log.Warn().Err(err).Msgf("set Unicode Ime failed") + return } } + encodedStr, err := utf7.Encoding.NewEncoder().String(text) + if err != nil { + log.Warn().Err(err).Msgf("encode text with modified utf7 failed") + return + } + err = ud.SendActionKey(encodedStr, options...) + return +} + +func (ud *uiaDriver) SendActionKey(text string, options ...ActionOption) (err error) { + actionOptions := NewActionOptions(options...) + var actions []interface{} + for i, c := range text { + actions = append(actions, map[string]interface{}{"type": "keyDown", "value": string(c)}, + map[string]interface{}{"type": "keyUp", "value": string(c)}) + if i != len(text)-1 { + actions = append(actions, map[string]interface{}{"type": "pause", "duration": 40}) + } + } + + data := map[string]interface{}{ + "actions": []interface{}{ + map[string]interface{}{ + "type": "key", + "id": "key", + "actions": actions, + }, + }, + } + + // new data options in post data for extra uiautomator configurations + actionOptions.updateData(data) + _, err = ud.httpPOST(data, "/session", ud.sessionId, "/actions/keys") return } @@ -492,3 +599,39 @@ func (ud *uiaDriver) Source(srcOpt ...SourceOption) (source string, err error) { source = reply.Value return } + +func (ud *uiaDriver) sourceTree(srcOpt ...SourceOption) (sourceTree *Hierarchy, err error) { + source, err := ud.Source() + if err != nil { + return + } + sourceTree = new(Hierarchy) + err = xml.Unmarshal([]byte(source), sourceTree) + if err != nil { + return + } + return +} + +func (ud *uiaDriver) TapByText(text string, options ...ActionOption) error { + sourceTree, err := ud.sourceTree() + if err != nil { + return err + } + return ud.tapByTextUsingHierarchy(sourceTree, text, options...) +} + +func (ud *uiaDriver) TapByTexts(actions ...TapTextAction) error { + sourceTree, err := ud.sourceTree() + if err != nil { + return err + } + + for _, action := range actions { + err := ud.tapByTextUsingHierarchy(sourceTree, action.Text, action.Options...) + if err != nil { + return err + } + } + return nil +} diff --git a/hrp/pkg/uixt/interface.go b/hrp/pkg/uixt/interface.go index e292d93c..3fccf5ba 100644 --- a/hrp/pkg/uixt/interface.go +++ b/hrp/pkg/uixt/interface.go @@ -511,6 +511,10 @@ type WebDriver interface { // since the location service needs some time to update the location data. Location() (Location, error) BatteryInfo() (BatteryInfo, error) + + // WindowSize Return the width and height in portrait mode. + // when getting the window size in wda/ui2/adb, if the device is in landscape mode, + // the width and height will be reversed. WindowSize() (Size, error) Screen() (Screen, error) Scale() (float64, error) @@ -537,6 +541,8 @@ type WebDriver interface { // StopCamera Stops the camera for recording StopCamera() error + Orientation() (orientation Orientation, err error) + // Tap Sends a tap event at the coordinate. Tap(x, y int, options ...ActionOption) error TapFloat(x, y float64, options ...ActionOption) error @@ -583,6 +589,11 @@ type WebDriver interface { // Source Return application elements tree Source(srcOpt ...SourceOption) (string, error) + + TapByText(text string, options ...ActionOption) error + + TapByTexts(actions ...TapTextAction) error + // AccessibleSource Return application elements accessibility tree AccessibleSource() (string, error) diff --git a/hrp/pkg/uixt/ios_driver.go b/hrp/pkg/uixt/ios_driver.go index ea2d305f..9acc164c 100644 --- a/hrp/pkg/uixt/ios_driver.go +++ b/hrp/pkg/uixt/ios_driver.go @@ -207,6 +207,14 @@ func (wd *wdaDriver) WindowSize() (size Size, err error) { } size.Height = size.Height * int(scale) size.Width = size.Width * int(scale) + orientation, err := wd.Orientation() + if err != nil { + log.Warn().Err(err).Msgf("window size get orientation failed, use default orientation") + orientation = OrientationPortrait + } + if orientation != OrientationPortrait { + size.Width, size.Height = size.Height, size.Width + } return } @@ -547,8 +555,8 @@ func (wd *wdaDriver) DragFloat(fromX, fromY, toX, toY float64, options ...Action // update data options in post data for extra WDA configurations actionOptions.updateData(data) - - _, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/dragfromtoforduration") + // wda 43 version + _, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/drag") return } @@ -751,6 +759,14 @@ func (wd *wdaDriver) Source(srcOpt ...SourceOption) (source string, err error) { return } +func (wd *wdaDriver) TapByText(text string, options ...ActionOption) error { + return errDriverNotImplemented +} + +func (wd *wdaDriver) TapByTexts(actions ...TapTextAction) error { + return errDriverNotImplemented +} + func (wd *wdaDriver) AccessibleSource() (source string, err error) { // [[FBRoute GET:@"/wda/accessibleSource"] respondWithTarget:self action:@selector(handleGetAccessibleSourceCommand:)] // [[FBRoute GET:@"/wda/accessibleSource"].withoutSession diff --git a/hrp/pkg/uixt/ios_test.go b/hrp/pkg/uixt/ios_test.go index 0e6a16f5..fdfdce70 100644 --- a/hrp/pkg/uixt/ios_test.go +++ b/hrp/pkg/uixt/ios_test.go @@ -10,17 +10,23 @@ import ( ) var ( - bundleId = "com.apple.Preferences" - driver WebDriver + bundleId = "com.apple.Preferences" + driver WebDriver + iOSDriverExt *DriverExt ) func setup(t *testing.T) { - device, err := NewIOSDevice() + device, err := NewIOSDevice(WithWDAPort(8700), WithWDAMjpegPort(8800), WithWDALogOn(true)) if err != nil { t.Fatal(err) } - - driver, err = device.NewUSBDriver(nil) + capabilities := NewCapabilities() + capabilities.WithDefaultAlertAction(AlertActionAccept) + driver, err = device.NewUSBDriver(capabilities) + if err != nil { + t.Fatal(err) + } + iOSDriverExt, err = newDriverExt(device, driver, nil) if err != nil { t.Fatal(err) } @@ -267,6 +273,16 @@ func Test_remoteWD_Drag(t *testing.T) { } } +func Test_Relative_Drag(t *testing.T) { + setup(t) + + // err := driver.Drag(200, 300, 200, 500, WithDataPressDuration(0.5)) + err := iOSDriverExt.SwipeRelative(0.5, 0.7, 0.5, 0.5) + if err != nil { + t.Fatal(err) + } +} + func Test_remoteWD_SetPasteboard(t *testing.T) { setup(t) @@ -305,12 +321,14 @@ func Test_remoteWD_GetPasteboard(t *testing.T) { func Test_remoteWD_SendKeys(t *testing.T) { setup(t) - - err := driver.SendKeys("App Store") + driver.StartCaptureLog("hrp_wda_log") + err := driver.SendKeys("", WithIdentifier("test")) + result, _ := driver.StopCaptureLog() // err := driver.SendKeys("App Store", WithFrequency(3)) if err != nil { t.Fatal(err) } + t.Log(result) } func Test_remoteWD_PressButton(t *testing.T) { @@ -374,7 +392,7 @@ func Test_remoteWD_Source(t *testing.T) { // t.Fatal(err) // } - source, err = driver.Source(NewSourceOption().WithScope("AppiumAUT")) + source, err = driver.Source() if err != nil { t.Fatal(err) } diff --git a/hrp/pkg/uixt/swipe.go b/hrp/pkg/uixt/swipe.go index 4c97d212..23d3490e 100644 --- a/hrp/pkg/uixt/swipe.go +++ b/hrp/pkg/uixt/swipe.go @@ -19,17 +19,30 @@ func assertRelative(p float64) bool { func (dExt *DriverExt) SwipeRelative(fromX, fromY, toX, toY float64, options ...ActionOption) error { width := dExt.windowSize.Width height := dExt.windowSize.Height + orientation, err := dExt.Driver.Orientation() + if err != nil { + log.Warn().Err(err).Msgf("swipe from (%v, %v) to (%v, %v) get orientation failed, use default orientation", + fromX, fromY, toX, toY) + orientation = OrientationPortrait + } if !assertRelative(fromX) || !assertRelative(fromY) || !assertRelative(toX) || !assertRelative(toY) { return fmt.Errorf("fromX(%f), fromY(%f), toX(%f), toY(%f) must be less than 1", fromX, fromY, toX, toY) } - - fromX = float64(width) * fromX - fromY = float64(height) * fromY - toX = float64(width) * toX - toY = float64(height) * toY + // 左转和右转都是"LANDSCAPE" + if orientation == OrientationPortrait { + fromX = float64(width) * fromX + fromY = float64(height) * fromY + toX = float64(width) * toX + toY = float64(height) * toY + } else { + fromX = float64(height) * fromX + fromY = float64(width) * fromY + toX = float64(height) * toX + toY = float64(width) * toY + } return dExt.Driver.SwipeFloat(fromX, fromY, toX, toY, options...) } diff --git a/hrp/pkg/uixt/tap.go b/hrp/pkg/uixt/tap.go index a992c94e..f52b5785 100644 --- a/hrp/pkg/uixt/tap.go +++ b/hrp/pkg/uixt/tap.go @@ -2,6 +2,7 @@ package uixt import ( "fmt" + "github.com/rs/zerolog/log" ) func (dExt *DriverExt) TapAbsXY(x, y float64, options ...ActionOption) error { @@ -15,9 +16,19 @@ func (dExt *DriverExt) TapXY(x, y float64, options ...ActionOption) error { return fmt.Errorf("x, y percentage should be <= 1, got x=%v, y=%v", x, y) } - x = x * float64(dExt.windowSize.Width) - y = y * float64(dExt.windowSize.Height) - + orientation, err := dExt.Driver.Orientation() + if err != nil { + log.Warn().Err(err).Msgf("tap (%v, %v) get orientation failed, use default orientation", + x, y) + orientation = OrientationPortrait + } + if orientation == OrientationPortrait { + x = x * float64(dExt.windowSize.Width) + y = y * float64(dExt.windowSize.Height) + } else { + x = x * float64(dExt.windowSize.Height) + y = y * float64(dExt.windowSize.Width) + } return dExt.TapAbsXY(x, y, options...) } @@ -86,7 +97,19 @@ func (dExt *DriverExt) DoubleTapXY(x, y float64) error { if x > 1 || y > 1 { return fmt.Errorf("x, y percentage should be < 1, got x=%v, y=%v", x, y) } - + orientation, err := dExt.Driver.Orientation() + if err != nil { + log.Warn().Err(err).Msgf("tap (%v, %v) get orientation failed, use default orientation", + x, y) + orientation = OrientationPortrait + } + if orientation == OrientationPortrait { + x = x * float64(dExt.windowSize.Width) + y = y * float64(dExt.windowSize.Height) + } else { + x = x * float64(dExt.windowSize.Height) + y = y * float64(dExt.windowSize.Width) + } x = x * float64(dExt.windowSize.Width) y = y * float64(dExt.windowSize.Height) return dExt.Driver.DoubleTapFloat(x, y) diff --git a/hrp/pkg/utf7/decoder.go b/hrp/pkg/utf7/decoder.go new file mode 100644 index 00000000..cfcba8c0 --- /dev/null +++ b/hrp/pkg/utf7/decoder.go @@ -0,0 +1,149 @@ +package utf7 + +import ( + "errors" + "unicode/utf16" + "unicode/utf8" + + "golang.org/x/text/transform" +) + +// ErrInvalidUTF7 means that a transformer encountered invalid UTF-7. +var ErrInvalidUTF7 = errors.New("utf7: invalid UTF-7") + +type decoder struct { + ascii bool +} + +func (d *decoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) { + for i := 0; i < len(src); i++ { + ch := src[i] + + if ch < min || ch > max { // Illegal code point in ASCII mode + err = ErrInvalidUTF7 + return + } + + if ch != '&' { + if nDst+1 > len(dst) { + err = transform.ErrShortDst + return + } + + nSrc++ + + dst[nDst] = ch + nDst++ + + d.ascii = true + continue + } + + // Find the end of the Base64 or "&-" segment + start := i + 1 + for i++; i < len(src) && src[i] != '-'; i++ { + if src[i] == '\r' || src[i] == '\n' { // base64 package ignores CR and LF + err = ErrInvalidUTF7 + return + } + } + + if i == len(src) { // Implicit shift ("&...") + if atEOF { + err = ErrInvalidUTF7 + } else { + err = transform.ErrShortSrc + } + return + } + + var b []byte + if i == start { // Escape sequence "&-" + b = []byte{'&'} + d.ascii = true + } else { // Control or non-ASCII code points in base64 + if !d.ascii { // Null shift ("&...-&...-") + err = ErrInvalidUTF7 + return + } + + b = decode(src[start:i]) + d.ascii = false + } + + if len(b) == 0 { // Bad encoding + err = ErrInvalidUTF7 + return + } + + if nDst+len(b) > len(dst) { + d.ascii = true + err = transform.ErrShortDst + return + } + + nSrc = i + 1 + + for _, ch := range b { + dst[nDst] = ch + nDst++ + } + } + + if atEOF { + d.ascii = true + } + + return +} + +func (d *decoder) Reset() { + d.ascii = true +} + +// Extracts UTF-16-BE bytes from base64 data and converts them to UTF-8. +// A nil slice is returned if the encoding is invalid. +func decode(b64 []byte) []byte { + var b []byte + + // Allocate a single block of memory large enough to store the Base64 data + // (if padding is required), UTF-16-BE bytes, and decoded UTF-8 bytes. + // Since a 2-byte UTF-16 sequence may expand into a 3-byte UTF-8 sequence, + // double the space allocation for UTF-8. + if n := len(b64); b64[n-1] == '=' { + return nil + } else if n&3 == 0 { + b = make([]byte, b64Enc.DecodedLen(n)*3) + } else { + n += 4 - n&3 + b = make([]byte, n+b64Enc.DecodedLen(n)*3) + copy(b[copy(b, b64):n], []byte("==")) + b64, b = b[:n], b[n:] + } + + // Decode Base64 into the first 1/3rd of b + n, err := b64Enc.Decode(b, b64) + if err != nil || n&1 == 1 { + return nil + } + + // Decode UTF-16-BE into the remaining 2/3rds of b + b, s := b[:n], b[n:] + j := 0 + for i := 0; i < n; i += 2 { + r := rune(b[i])<<8 | rune(b[i+1]) + if utf16.IsSurrogate(r) { + if i += 2; i == n { + return nil + } + r2 := rune(b[i])<<8 | rune(b[i+1]) + if r = utf16.DecodeRune(r, r2); r == repl { + return nil + } + } else if min <= r && r <= max { + return nil + } + j += utf8.EncodeRune(s[j:], r) + } + return s[:j] +} diff --git a/hrp/pkg/utf7/encoder.go b/hrp/pkg/utf7/encoder.go new file mode 100644 index 00000000..8414d109 --- /dev/null +++ b/hrp/pkg/utf7/encoder.go @@ -0,0 +1,91 @@ +package utf7 + +import ( + "unicode/utf16" + "unicode/utf8" + + "golang.org/x/text/transform" +) + +type encoder struct{} + +func (e *encoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) { + for i := 0; i < len(src); { + ch := src[i] + + var b []byte + if min <= ch && ch <= max { + b = []byte{ch} + if ch == '&' { + b = append(b, '-') + } + + i++ + } else { + start := i + + // Find the next printable ASCII code point + i++ + for i < len(src) && (src[i] < min || src[i] > max) { + i++ + } + + if !atEOF && i == len(src) { + err = transform.ErrShortSrc + return + } + + b = encode(src[start:i]) + } + + if nDst+len(b) > len(dst) { + err = transform.ErrShortDst + return + } + + nSrc = i + + for _, ch := range b { + dst[nDst] = ch + nDst++ + } + } + + return +} + +func (e *encoder) Reset() {} + +// Converts string s from UTF-8 to UTF-16-BE, encodes the result as base64, +// removes the padding, and adds UTF-7 shifts. +func encode(s []byte) []byte { + // len(s) is sufficient for UTF-8 to UTF-16 conversion if there are no + // control code points (see table below). + b := make([]byte, 0, len(s)+4) + for len(s) > 0 { + r, size := utf8.DecodeRune(s) + if r > utf8.MaxRune { + r, size = utf8.RuneError, 1 // Bug fix (issue 3785) + } + s = s[size:] + if r1, r2 := utf16.EncodeRune(r); r1 != repl { + b = append(b, byte(r1>>8), byte(r1)) + r = r2 + } + b = append(b, byte(r>>8), byte(r)) + } + + // Encode as base64 + n := b64Enc.EncodedLen(len(b)) + 2 + b64 := make([]byte, n) + b64Enc.Encode(b64[1:], b) + + // Strip padding + n -= 2 - (len(b)+2)%3 + b64 = b64[:n] + + // Add UTF-7 shifts + b64[0] = '&' + b64[n-1] = '-' + return b64 +} diff --git a/hrp/pkg/utf7/utf7.go b/hrp/pkg/utf7/utf7.go new file mode 100644 index 00000000..b9dd9623 --- /dev/null +++ b/hrp/pkg/utf7/utf7.go @@ -0,0 +1,34 @@ +// Package utf7 implements modified UTF-7 encoding defined in RFC 3501 section 5.1.3 +package utf7 + +import ( + "encoding/base64" + + "golang.org/x/text/encoding" +) + +const ( + min = 0x20 // Minimum self-representing UTF-7 value + max = 0x7E // Maximum self-representing UTF-7 value + + repl = '\uFFFD' // Unicode replacement code point +) + +var b64Enc = base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,") + +type enc struct{} + +func (e enc) NewDecoder() *encoding.Decoder { + return &encoding.Decoder{ + Transformer: &decoder{true}, + } +} + +func (e enc) NewEncoder() *encoding.Encoder { + return &encoding.Encoder{ + Transformer: &encoder{}, + } +} + +// Encoding is the modified UTF-7 encoding. +var Encoding encoding.Encoding = enc{}