From cc81c00a8205c71a5953f43fe3e0e8639853a798 Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Thu, 6 Mar 2025 16:57:51 +0800 Subject: [PATCH] feat: add adb screen record --- internal/version/VERSION | 2 +- pkg/gadb/device.go | 23 ++++++- server/source.go | 2 +- uixt/android_driver_adb.go | 100 ++++++++++++++++++++++------- uixt/android_test.go | 2 +- uixt/browser_driver.go | 2 +- uixt/driver.go | 3 +- uixt/driver_action.go | 2 +- uixt/driver_ext/ios_stub_driver.go | 4 +- uixt/harmony_driver_hdc.go | 2 +- uixt/ios_driver_wda.go | 5 +- uixt/ios_test.go | 2 +- uixt/option/action.go | 7 +- uixt/option/screen.go | 46 ++++++++++++- 14 files changed, 159 insertions(+), 43 deletions(-) diff --git a/internal/version/VERSION b/internal/version/VERSION index a2ecc7dd..5f22f65d 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-beta-2503061328 +v5.0.0-beta-2503061657 diff --git a/pkg/gadb/device.go b/pkg/gadb/device.go index 63407da9..fcc81c3e 100644 --- a/pkg/gadb/device.go +++ b/pkg/gadb/device.go @@ -745,7 +745,7 @@ func (d *Device) ScreenCap() ([]byte, error) { time.Now().Unix()) _, err := d.RunShellCommandWithBytes("screencap", "-p", tempPath) if err != nil { - return nil, err + return nil, errors.Wrap(err, "screencap failed") } // remove temp file @@ -755,5 +755,24 @@ func (d *Device) ScreenCap() ([]byte, error) { buffer := bytes.NewBuffer(nil) err = d.Pull(tempPath, buffer) - return buffer.Bytes(), err + if err != nil { + return nil, errors.Wrap(err, "pull video failed") + } + return buffer.Bytes(), nil +} + +func (d *Device) ScreenRecord(seconds float64) ([]byte, error) { + videoPath := fmt.Sprintf("/sdcard/screenrecord_%d.mp4", time.Now().Unix()) + _, err := d.RunShellCommandWithBytes("screenrecord", + "--time-limit", fmt.Sprintf("%.1f", seconds), videoPath) + if err != nil { + return nil, errors.Wrap(err, "screenrecord failed") + } + + buffer := bytes.NewBuffer(nil) + err = d.Pull(videoPath, buffer) + if err != nil { + return nil, errors.Wrap(err, "pull video failed") + } + return buffer.Bytes(), nil } diff --git a/server/source.go b/server/source.go index 87cbec7a..aac7e204 100644 --- a/server/source.go +++ b/server/source.go @@ -37,7 +37,7 @@ func (r *Router) screenResultHandler(c *gin.Context) { var actionOptions []option.ActionOption if screenReq.Options != nil { - actionOptions = screenReq.Options.Options() + actionOptions = screenReq.Options.GetScreenShotOptions() } screenResult, err := driver.GetScreenResult(actionOptions...) diff --git a/uixt/android_driver_adb.go b/uixt/android_driver_adb.go index 1026ce7c..f13050ef 100644 --- a/uixt/android_driver_adb.go +++ b/uixt/android_driver_adb.go @@ -3,6 +3,7 @@ package uixt import ( "bufio" "bytes" + "context" "encoding/json" "encoding/xml" "fmt" @@ -779,54 +780,105 @@ func (ad *ADBDriver) GetIme() (ime string, err error) { return currentIme, nil } -func (ad *ADBDriver) ScreenRecord(duration time.Duration) (videoPath string, err error) { - timestamp := time.Now().Format("20060102_150405") + fmt.Sprintf("_%03d", time.Now().UnixNano()/1e6%1000) - fileName := filepath.Join(config.GetConfig().ScreenShotsPath, fmt.Sprintf("%s.mp4", timestamp)) +func (ad *ADBDriver) ScreenRecord(opts ...option.ActionOption) (videoPath string, err error) { + options := option.NewActionOptions(opts...) - file, err := os.Create(fileName) + var filePath string + if options.ScreenRecordPath != "" { + filePath = options.ScreenRecordPath + } else { + timestamp := time.Now().Format("20060102_150405") + fmt.Sprintf("_%03d", time.Now().UnixNano()/1e6%1000) + filePath = filepath.Join(config.GetConfig().ScreenShotsPath, fmt.Sprintf("%s.mp4", timestamp)) + } + + duration := options.Duration + audioOn := options.ScreenRecordWithAudio + + // get android system version + var sysVersion int + if systemVersion, err := ad.Device.SystemVersion(); err == nil { + if version, err := strconv.Atoi(systemVersion); err == nil { + sysVersion = version + } + } + if sysVersion == 0 { + log.Warn().Err(err).Msg("get android system version failed") + } + + var useAdbScreenRecord bool + if !audioOn { + log.Info().Bool("audioOn", audioOn).Msg("screen record with adb screenrecord by default") + useAdbScreenRecord = true + } else if sysVersion != 0 && sysVersion < 11 { + // scrcpy audio forwarding is supported for devices with Android 11 or higher + // https://github.com/Genymobile/scrcpy/blob/master/doc/audio.md + log.Warn().Bool("audioOn", audioOn).Int("version", sysVersion). + Msg("Audio disabled, it is only supported for Android >= 11, use adb screenrecord") + useAdbScreenRecord = true + } + + if useAdbScreenRecord { + res, err := ad.Device.ScreenRecord(duration) + if err != nil { + return "", errors.Wrap(err, "screen record failed") + } + if err := os.WriteFile(filePath, res, 0o644); err != nil { + return "", errors.Wrap(err, "write screen record file failed") + } + return filePath, nil + } + + // screen record with audio + log.Info().Msg("screen record with audio, use scrcpy") + + file, err := os.Create(filePath) if err != nil { - log.Error().Err(err) - return "", err + return "", errors.Wrap(err, "create screen record file failed") } defer func() { _ = file.Close() }() - // scrcpy -s 7d21bb91 --record=file.mp4 -N + // start scrcpy cmd := exec.Command( "scrcpy", "-s", ad.Device.Serial(), - fmt.Sprintf("--record=%s", fileName), - "-N", + fmt.Sprintf("--record=%s", filePath), + "--record-format=mp4", + "--max-fps=30", + "--no-playback", // Disable video and audio playback on the computer ) cmd.Stdout = io.Discard cmd.Stderr = io.Discard - // 启动命令 - if err := cmd.Start(); err != nil { - log.Error().Err(err) - return "", err - } - timer := time.After(duration) - done := make(chan error) + if err := cmd.Start(); err != nil { + return "", errors.Wrap(err, "start screen record failed") + } + + ctx, cancel := context.WithTimeout(context.Background(), + time.Duration(duration*float64(time.Second))) + defer cancel() + + done := make(chan error, 1) go func() { - // 等待 ffmpeg 命令执行完毕 done <- cmd.Wait() }() + select { - case <-timer: - // 超时,停止 scrcpy 进程 + case <-ctx.Done(): + // 超时,优雅停止 scrcpy 进程 if err := cmd.Process.Signal(syscall.SIGINT); err != nil { - log.Error().Err(err) + log.Error().Err(err).Msg("failed to stop scrcpy process") + _ = cmd.Process.Kill() // 强制结束进程 } + <-done // 等待进程完全退出 case err := <-done: - // ffmpeg 正常结束 if err != nil { - log.Error().Err(err) - return "", err + return "", errors.Wrap(err, "screen record failed") } } - return filepath.Abs(fileName) + + return filepath.Abs(filePath) } func (ad *ADBDriver) Setup() error { diff --git a/uixt/android_test.go b/uixt/android_test.go index f492740c..a67166c0 100644 --- a/uixt/android_test.go +++ b/uixt/android_test.go @@ -195,7 +195,7 @@ func TestDriver_ADB_ForegroundInfo(t *testing.T) { func TestDriver_ADB_ScreenRecord(t *testing.T) { driver := setupADBDriverExt(t) - path, err := driver.ScreenRecord(5 * time.Second) + path, err := driver.ScreenRecord(option.WithScreenRecordDuation(5)) assert.Nil(t, err) defer os.Remove(path) t.Log(path) diff --git a/uixt/browser_driver.go b/uixt/browser_driver.go index 95d08123..7a455529 100644 --- a/uixt/browser_driver.go +++ b/uixt/browser_driver.go @@ -614,7 +614,7 @@ func (wd *BrowserDriver) GetSession() *DriverSession { return nil } -func (wd *BrowserDriver) ScreenRecord(duration time.Duration) (videoPath string, err error) { +func (wd *BrowserDriver) ScreenRecord(opts ...option.ActionOption) (videoPath string, err error) { return } diff --git a/uixt/driver.go b/uixt/driver.go index a27a9a48..70accc51 100644 --- a/uixt/driver.go +++ b/uixt/driver.go @@ -4,7 +4,6 @@ import ( "bytes" _ "image/gif" _ "image/png" - "time" "github.com/httprunner/httprunner/v5/uixt/ai" "github.com/httprunner/httprunner/v5/uixt/option" @@ -37,7 +36,7 @@ type IDriver interface { ForegroundInfo() (app types.AppInfo, err error) WindowSize() (types.Size, error) ScreenShot(opts ...option.ActionOption) (*bytes.Buffer, error) - ScreenRecord(duration time.Duration) (videoPath string, err error) + ScreenRecord(opts ...option.ActionOption) (videoPath string, err error) Source(srcOpt ...option.SourceOption) (string, error) Orientation() (orientation types.Orientation, err error) Rotation() (rotation types.Rotation, err error) diff --git a/uixt/driver_action.go b/uixt/driver_action.go index e20e261c..42a8ff66 100644 --- a/uixt/driver_action.go +++ b/uixt/driver_action.go @@ -273,7 +273,7 @@ func (dExt *XTDriver) DoAction(action MobileAction) (err error) { case ACTION_ScreenShot: // take screenshot log.Info().Msg("take screenshot for current screen") - _, err := dExt.GetScreenResult(action.GetScreenOptions()...) + _, err := dExt.GetScreenResult(action.GetScreenShotOptions()...) return err case ACTION_ClosePopups: return dExt.ClosePopupsHandler() diff --git a/uixt/driver_ext/ios_stub_driver.go b/uixt/driver_ext/ios_stub_driver.go index 948ff9be..6dd7a980 100644 --- a/uixt/driver_ext/ios_stub_driver.go +++ b/uixt/driver_ext/ios_stub_driver.go @@ -364,11 +364,11 @@ func (s *StubIOSDriver) WindowSize() (types.Size, error) { return s.WDADriver.WindowSize() } -func (s *StubIOSDriver) ScreenRecord(duration time.Duration) (videoPath string, err error) { +func (s *StubIOSDriver) ScreenRecord(opts ...option.ActionOption) (videoPath string, err error) { if err := s.SetupWda(); err != nil { return "", errors.Wrap(code.DeviceHTTPDriverError, err.Error()) } - return s.WDADriver.ScreenRecord(duration) + return s.WDADriver.ScreenRecord(opts...) } func (s *StubIOSDriver) Orientation() (types.Orientation, error) { diff --git a/uixt/harmony_driver_hdc.go b/uixt/harmony_driver_hdc.go index 2c0f0698..fb248791 100644 --- a/uixt/harmony_driver_hdc.go +++ b/uixt/harmony_driver_hdc.go @@ -253,7 +253,7 @@ func (hd *HDCDriver) StopCaptureLog() (result interface{}, err error) { return hd.points, nil } -func (hd *HDCDriver) ScreenRecord(duration time.Duration) (videoPath string, err error) { +func (hd *HDCDriver) ScreenRecord(opts ...option.ActionOption) (videoPath string, err error) { return "", nil } diff --git a/uixt/ios_driver_wda.go b/uixt/ios_driver_wda.go index 5d6f6381..8a97a19b 100644 --- a/uixt/ios_driver_wda.go +++ b/uixt/ios_driver_wda.go @@ -875,10 +875,13 @@ func (wd *WDADriver) triggerWDALog(data map[string]interface{}) (rawResp []byte, return wd.Session.POST(data, "/gtf/automation/log") } -func (wd *WDADriver) ScreenRecord(duration time.Duration) (videoPath string, err error) { +func (wd *WDADriver) ScreenRecord(opts ...option.ActionOption) (videoPath string, err error) { timestamp := time.Now().Format("20060102_150405") + fmt.Sprintf("_%03d", time.Now().UnixNano()/1e6%1000) fileName := filepath.Join(config.GetConfig().ScreenShotsPath, fmt.Sprintf("%s.mp4", timestamp)) + options := option.NewActionOptions(opts...) + duration := time.Duration(options.Duration * float64(time.Second)) + file, err := os.Create(fileName) if err != nil { fmt.Println("Error creating file:", err) diff --git a/uixt/ios_test.go b/uixt/ios_test.go index 98a60a57..6fe37bfd 100644 --- a/uixt/ios_test.go +++ b/uixt/ios_test.go @@ -302,7 +302,7 @@ func TestDriver_WDA_AccessibleSource(t *testing.T) { func TestDriver_WDA_ScreenRecord(t *testing.T) { driver := setupWDADriverExt(t) - path, err := driver.ScreenRecord(5 * time.Second) + path, err := driver.ScreenRecord(option.WithScreenRecordDuation(5)) assert.Nil(t, err) t.Log(path) } diff --git a/uixt/option/action.go b/uixt/option/action.go index 4447638a..9692ba6b 100644 --- a/uixt/option/action.go +++ b/uixt/option/action.go @@ -120,11 +120,10 @@ func (o *ActionOptions) Options() []ActionOption { } } - return options -} + options = append(options, o.GetScreenShotOptions()...) + options = append(options, o.GetScreenRecordOptions()...) -func (o *ActionOptions) GetScreenOptions() []ActionOption { - return o.ScreenOptions.Options() + return options } func (o *ActionOptions) ApplyOffset(absX, absY float64) (float64, float64) { diff --git a/uixt/option/screen.go b/uixt/option/screen.go index 60af4b31..d516b93a 100644 --- a/uixt/option/screen.go +++ b/uixt/option/screen.go @@ -4,6 +4,7 @@ import "github.com/httprunner/httprunner/v5/uixt/types" type ScreenOptions struct { ScreenShotOptions + ScreenRecordOptions ScreenFilterOptions } @@ -18,7 +19,7 @@ type ScreenShotOptions struct { ScreenShotFileName string `json:"screenshot_file_name,omitempty" yaml:"screenshot_file_name,omitempty"` } -func (o *ScreenShotOptions) Options() []ActionOption { +func (o *ScreenShotOptions) GetScreenShotOptions() []ActionOption { options := make([]ActionOption, 0) if o == nil { return options @@ -125,6 +126,49 @@ func WithScreenShotFileName(fileName string) ActionOption { } } +type ScreenRecordOptions struct { + ScreenRecordDuration float64 `json:"screenrecord_duration,omitempty" yaml:"screenrecord_duration,omitempty"` + ScreenRecordWithAudio bool `json:"screenrecord_with_audio,omitempty" yaml:"screenrecord_with_audio,omitempty"` + ScreenRecordPath string `json:"screenrecord_path,omitempty" yaml:"screenrecord_path,omitempty"` +} + +func (o *ScreenRecordOptions) GetScreenRecordOptions() []ActionOption { + options := make([]ActionOption, 0) + if o == nil { + return options + } + + // screen record options + if o.ScreenRecordDuration > 0 { + options = append(options, WithDuration(o.ScreenRecordDuration)) + } + if o.ScreenRecordWithAudio { + options = append(options, WithScreenRecordAudio(true)) + } + if o.ScreenRecordPath != "" { + options = append(options, WithScreenRecordPath(o.ScreenRecordPath)) + } + return options +} + +func WithScreenRecordDuation(duration float64) ActionOption { + return func(o *ActionOptions) { + o.ScreenRecordDuration = duration + } +} + +func WithScreenRecordAudio(audioOn bool) ActionOption { + return func(o *ActionOptions) { + o.ScreenRecordWithAudio = audioOn + } +} + +func WithScreenRecordPath(path string) ActionOption { + return func(o *ActionOptions) { + o.ScreenRecordPath = path + } +} + // (x1, y1) is the top left corner, (x2, y2) is the bottom right corner // [x1, y1, x2, y2] in percentage of the screen type Scope []float64