httprunner/uixt/android_device.go

528 lines
14 KiB
Go

package uixt
import (
"bufio"
"bytes"
"context"
"crypto/md5"
"embed"
"encoding/base64"
"encoding/hex"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"github.com/httprunner/funplugin/myexec"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/internal/config"
"github.com/httprunner/httprunner/v5/internal/json"
"github.com/httprunner/httprunner/v5/pkg/gadb"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/httprunner/httprunner/v5/uixt/types"
)
const (
EvalInstallerPackageName = "sogou.mobile.explorer"
InstallViaInstallerCommand = "am start -S -n sogou.mobile.explorer/.PackageInstallerActivity -d"
)
//go:embed evalite
var evalite embed.FS
func NewAndroidDevice(opts ...option.AndroidDeviceOption) (device *AndroidDevice, err error) {
androidOptions := option.NewAndroidDeviceOptions(opts...)
// get all attached android devices
adbClient, err := gadb.NewClientWith(
androidOptions.AdbServerHost, androidOptions.AdbServerPort)
if err != nil {
return nil, errors.Wrap(code.DeviceConnectionError, err.Error())
}
devices, err := adbClient.DeviceList()
if err != nil {
return nil, errors.Wrap(code.DeviceConnectionError, err.Error())
}
if len(devices) == 0 {
return nil, errors.Wrapf(code.DeviceConnectionError,
"no attached android devices")
}
// filter device by serial
var gadbDevice *gadb.Device
if androidOptions.SerialNumber == "" {
if len(devices) > 1 {
return nil, errors.Wrap(code.DeviceConnectionError,
"more than one device connected, please specify the serial")
}
gadbDevice = devices[0]
androidOptions.SerialNumber = gadbDevice.Serial()
log.Warn().Str("serial", androidOptions.SerialNumber).
Msg("android SerialNumber is not specified, select the attached one")
} else {
for _, d := range devices {
if d.Serial() == androidOptions.SerialNumber {
gadbDevice = d
break
}
}
if gadbDevice == nil {
return nil, errors.Wrapf(code.DeviceConnectionError,
"android device %s not attached", androidOptions.SerialNumber)
}
}
device = &AndroidDevice{
Device: gadbDevice,
Options: androidOptions,
Logcat: NewAdbLogcat(androidOptions.SerialNumber),
}
log.Info().Str("serial", device.Options.SerialNumber).Msg("init android device")
// setup device
if err := device.Setup(); err != nil {
return nil, errors.Wrap(err, "setup android device failed")
}
return device, nil
}
type AndroidDevice struct {
*gadb.Device
Options *option.AndroidDeviceOptions
Logcat *AdbLogcat
}
func (dev *AndroidDevice) Setup() error {
dev.Device.RunShellCommand("ime", "enable", option.UnicodeImePackageName)
dev.Device.RunShellCommand("rm", "-r", config.GetConfig().DeviceActionLogFilePath)
// setup evalite
evalToolRaw, err := evalite.ReadFile("evalite")
if err != nil {
return errors.Wrap(code.LoadFileError, err.Error())
}
err = dev.Device.Push(bytes.NewReader(evalToolRaw), "/data/local/tmp/evalite", time.Now())
if err != nil {
return errors.Wrap(code.DeviceShellExecError, err.Error())
}
return nil
}
func (dev *AndroidDevice) Teardown() error {
return nil
}
func (dev *AndroidDevice) UUID() string {
return dev.Options.SerialNumber
}
func (dev *AndroidDevice) LogEnabled() bool {
return dev.Options.LogOn
}
func (dev *AndroidDevice) NewDriver() (driver IDriver, err error) {
if dev.Options.UIA2 || dev.Options.LogOn {
driver, err = NewUIA2Driver(dev)
} else {
driver, err = NewADBDriver(dev)
}
if err != nil {
return nil, errors.Wrap(err, "failed to init UIA driver")
}
if dev.Options.LogOn {
err = driver.StartCaptureLog("hrp_adb_log")
if err != nil {
return nil, err
}
}
return driver, nil
}
func (dev *AndroidDevice) Install(apkPath string, opts ...option.InstallOption) error {
installOpts := option.NewInstallOptions(opts...)
brand, err := dev.Device.Brand()
if err != nil {
return err
}
args := []string{}
if installOpts.Reinstall {
args = append(args, "-r")
}
if installOpts.GrantPermission {
args = append(args, "-g")
}
if installOpts.Downgrade {
args = append(args, "-d")
}
switch strings.ToLower(brand) {
case "vivo":
return dev.installVivoSilent(apkPath, args...)
case "oppo", "realme", "oneplus":
if dev.Device.IsPackageInstalled(EvalInstallerPackageName) {
return dev.installViaInstaller(apkPath, args...)
}
log.Warn().Msg("oppo not install eval installer")
return dev.installCommon(apkPath, args...)
default:
return dev.installCommon(apkPath, args...)
}
}
func (dev *AndroidDevice) installVivoSilent(apkPath string, args ...string) error {
currentTime := builtin.GetCurrentDay()
md5HashInBytes := md5.Sum([]byte(currentTime))
verifyCode := hex.EncodeToString(md5HashInBytes[:])
verifyCode = base64.StdEncoding.EncodeToString([]byte(verifyCode))
verifyCode = verifyCode[:8]
verifyCode = "-V" + verifyCode
args = append([]string{verifyCode}, args...)
_, err := dev.Device.InstallAPK(apkPath, args...)
return err
}
func (dev *AndroidDevice) installViaInstaller(apkPath string, args ...string) error {
appRemotePath := "/data/local/tmp/" + strconv.FormatInt(time.Now().UnixMilli(), 10) + ".apk"
err := dev.Device.PushFile(apkPath, appRemotePath, time.Now())
if err != nil {
return err
}
done := make(chan error)
defer func() {
close(done)
}()
logcat := NewAdbLogcatWithCallback(dev.Device.Serial(), func(line string) {
re := regexp.MustCompile(`\{.*?}`)
match := re.FindString(line)
if match == "" {
return
}
var result InstallResult
err := json.Unmarshal([]byte(match), &result)
if err != nil {
log.Warn().Msg("parse Install msg line error: " + match)
return
}
if result.Result == 0 {
// 安装成功
done <- nil
} else {
done <- errors.New(match)
}
})
err = logcat.CatchLogcat("PackageInstallerCallback")
if err != nil {
return err
}
defer func() {
_ = logcat.Stop()
}()
// 需要监听是否完成安装
command := strings.Split(InstallViaInstallerCommand, " ")
args = append(command, appRemotePath)
_, err = dev.Device.RunShellCommand("am", args[1:]...)
if err != nil {
return err
}
// 等待安装完成或超时
timeout := 3 * time.Minute
select {
case err := <-done:
return err
case <-time.After(timeout):
return fmt.Errorf("installation timed out after %v", timeout)
}
}
type InstallResult struct {
Result int `json:"result"`
ErrorCode int `json:"errorCode"`
ErrorMsg string `json:"errorMsg"`
}
func (dev *AndroidDevice) installCommon(apkPath string, args ...string) error {
_, err := dev.Device.InstallAPK(apkPath, args...)
return err
}
func (dev *AndroidDevice) Uninstall(packageName string) error {
_, err := dev.Device.Uninstall(packageName)
return err
}
func (dev *AndroidDevice) GetCurrentWindow() (windowInfo types.WindowInfo, err error) {
// adb shell dumpsys window | grep -E 'mCurrentFocus|mFocusedApp'
output, err := dev.Device.RunShellCommand("dumpsys", "window", "|", "grep", "-E", "'mCurrentFocus|mFocusedApp'")
if err != nil {
return types.WindowInfo{}, errors.Wrap(err, "get current window failed")
}
// mCurrentFocus=Window{a33bc55 u0 com.miui.home/com.miui.home.launcher.Launcher}
reFocus := regexp.MustCompile(`mCurrentFocus=Window{.*? (\S+)/(\S+)}`)
matches := reFocus.FindStringSubmatch(output)
if len(matches) == 3 {
windowInfo = types.WindowInfo{
PackageName: matches[1],
Activity: matches[2],
}
return windowInfo, nil
}
// mFocusedApp=ActivityRecord{2db504f u0 com.miui.home/.launcher.Launcher t2}
reApp := regexp.MustCompile(`mFocusedApp=ActivityRecord{.*? (\S+)/(\S+?)\s`)
matches = reApp.FindStringSubmatch(output)
if len(matches) == 3 {
windowInfo = types.WindowInfo{
PackageName: matches[1],
Activity: matches[2],
}
return windowInfo, nil
}
// adb shell dumpsys activity activities | grep mResumedActivity
output, err = dev.Device.RunShellCommand("dumpsys", "activity", "activities", "|", "grep", "mResumedActivity")
if err != nil {
return types.WindowInfo{}, errors.Wrap(err, "get current activity failed")
}
// mResumedActivity: ActivityRecord{2db504f u0 com.miui.home/.launcher.Launcher t2}
reActivity := regexp.MustCompile(`mResumedActivity: ActivityRecord{.*? (\S+)/(\S+?)\s`)
matches = reActivity.FindStringSubmatch(output)
if len(matches) == 3 {
windowInfo = types.WindowInfo{
PackageName: matches[1],
Activity: matches[2],
}
return windowInfo, nil
}
return types.WindowInfo{}, errors.New("failed to extract current window")
}
func (dev *AndroidDevice) GetPackageInfo(packageName string) (types.AppInfo, error) {
appInfo := types.AppInfo{
Name: packageName,
}
// get package version
appVersion, err := dev.getPackageVersion(packageName)
if err == nil {
appInfo.AppBaseInfo.VersionName = appVersion
} else {
log.Warn().Msg("failed to get package version")
return appInfo, errors.Wrap(code.DeviceAppNotInstalled, err.Error())
}
// get package path
packagePath, err := dev.getPackagePath(packageName)
if err == nil {
appInfo.AppBaseInfo.AppPath = packagePath
} else {
log.Warn().Msg("failed to get package path")
return appInfo, errors.Wrap(code.DeviceAppNotInstalled, err.Error())
}
// get package md5
packageMD5, err := dev.getPackageMD5(packagePath)
if err == nil {
appInfo.AppBaseInfo.AppMD5 = packageMD5
} else {
log.Warn().Msg("failed to get package md5")
return appInfo, errors.Wrap(code.DeviceAppNotInstalled, err.Error())
}
log.Info().Interface("appInfo", appInfo).Msg("get package info")
return appInfo, nil
}
func (dev *AndroidDevice) ScreenShot() (*bytes.Buffer, error) {
raw, err := dev.Device.ScreenCap()
if err != nil {
return nil, errors.Wrapf(code.DeviceScreenShotError,
"adb screencap failed %v", err)
}
return bytes.NewBuffer(raw), nil
}
func (dev *AndroidDevice) GetAppInfo(packageName string) (app types.AppInfo, err error) {
packageInfo, err := dev.RunShellCommand(
"CLASSPATH=/data/local/tmp/evalite", "app_process", "/",
"com.bytedance.iesqa.eval_process.PackageService", packageName, "2>/dev/null")
if packageInfo == "" {
return app, nil
}
if err != nil {
return app, err
}
err = json.Unmarshal([]byte(strings.TrimSpace(packageInfo)), &app)
if err != nil {
log.Error().Err(err).Str("packageInfo", packageInfo)
}
return
}
func (dev *AndroidDevice) getPackageVersion(packageName string) (string, error) {
output, err := dev.Device.RunShellCommand("dumpsys", "package", packageName, "|", "grep", "versionName")
if err != nil {
return "", errors.Wrap(err, "get package version failed")
}
appVersion := ""
re := regexp.MustCompile(`versionName=(.+)`)
matches := re.FindStringSubmatch(output)
if len(matches) > 1 {
appVersion = matches[1]
return appVersion, nil
}
return "", errors.New("failed to get package version")
}
func (dev *AndroidDevice) getPackagePath(packageName string) (string, error) {
output, err := dev.Device.RunShellCommand("pm", "path", packageName)
if err != nil {
return "", errors.Wrap(err, "get package path failed")
}
re := regexp.MustCompile(`package:(.+)`)
matches := re.FindStringSubmatch(output)
if len(matches) > 1 {
return matches[1], nil
}
return "", errors.New("failed to get package path")
}
func (dev *AndroidDevice) getPackageMD5(packagePath string) (string, error) {
output, err := dev.Device.RunShellCommand("md5sum", packagePath)
if err != nil {
return "", errors.Wrap(err, "get package md5 failed")
}
matches := strings.Split(output, " ")
if len(matches) > 1 {
return matches[0], nil
}
return "", errors.New("failed to get package md5")
}
type LineCallback func(string)
type AdbLogcat struct {
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{}),
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 {
return
}
go func() {
select {
case <-timeoutCtx.Done():
_ = l.Stop()
case <-l.stopping:
}
}()
return
}
func (l *AdbLogcat) Stop() error {
select {
case <-l.stopping:
default:
close(l.stopping)
<-l.done
close(l.done)
}
return l.Errors()
}
func (l *AdbLogcat) Errors() (err error) {
for _, e := range l.errs {
if err != nil {
err = fmt.Errorf("%v |[DeviceLogcatErr] %v", err, e)
} else {
err = fmt.Errorf("[DeviceLogcatErr] %v", e)
}
}
return
}
func (l *AdbLogcat) CatchLogcat(filter string) (err error) {
if l.cmd != nil {
log.Warn().Msg("logcat already start")
return nil
}
// FIXME: replace with gadb shell command
// clear logcat
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", 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
}