From 8b0a1509ea08a6150f57e51d5142197a5863d068 Mon Sep 17 00:00:00 2001 From: "farhan.labib" Date: Tue, 12 May 2026 17:15:40 +0600 Subject: [PATCH 1/2] feat(android): add device.io.animation-scales.get/set RPC methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uiautomator dump fails with "no XML content found" on screens that are continuously animated (SurfaceView/TextureView that never settles). The fix is to disable Android's three global animation scales before triggering the dump, then restore the original values afterward. Adds two new RPC methods (Android only): - device.io.animation-scales.get — returns current window/transition/animator scale values - device.io.animation-scales.set — writes all three scales atomically Callers should GET first, then SET to 0, dump, then SET back to the saved values. This avoids assuming animations were at 1 before the call. iOS and iOS Simulator return a clear "not supported" error. --- commands/animation.go | 83 +++++++++++++++++++++++++++++++++++++++++++ devices/android.go | 47 ++++++++++++++++++++++++ devices/common.go | 10 ++++++ devices/ios.go | 8 +++++ devices/remote.go | 16 +++++++++ devices/simulator.go | 8 +++++ server/dispatch.go | 6 ++-- server/server.go | 54 ++++++++++++++++++++++++++++ 8 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 commands/animation.go diff --git a/commands/animation.go b/commands/animation.go new file mode 100644 index 0000000..d2a07fa --- /dev/null +++ b/commands/animation.go @@ -0,0 +1,83 @@ +package commands + +import ( + "fmt" + + "github.com/mobile-next/mobilecli/devices" +) + +// AnimationScalesGetRequest represents the request for getting animation scales +type AnimationScalesGetRequest struct { + DeviceID string `json:"deviceId"` +} + +// AnimationScalesSetRequest represents the request for setting animation scales +type AnimationScalesSetRequest struct { + DeviceID string `json:"deviceId"` + Window float64 `json:"window"` + Transition float64 `json:"transition"` + Animator float64 `json:"animator"` +} + +// AnimationScalesResponse holds the three Android global animation scale values +type AnimationScalesResponse struct { + Window float64 `json:"window"` + Transition float64 `json:"transition"` + Animator float64 `json:"animator"` +} + +// AnimationScalesGetCommand gets the current animation scale values from the device +func AnimationScalesGetCommand(req AnimationScalesGetRequest) *CommandResponse { + device, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + + err = device.StartAgent(devices.StartAgentConfig{ + Hook: GetShutdownHook(), + }) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to start agent on device %s: %v", device.ID(), err)) + } + + scales, err := device.GetAnimationScales() + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to get animation scales: %v", err)) + } + + return NewSuccessResponse(AnimationScalesResponse{ + Window: scales.Window, + Transition: scales.Transition, + Animator: scales.Animator, + }) +} + +// AnimationScalesSetCommand sets the animation scale values on the device +func AnimationScalesSetCommand(req AnimationScalesSetRequest) *CommandResponse { + device, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + + err = device.StartAgent(devices.StartAgentConfig{ + Hook: GetShutdownHook(), + }) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to start agent on device %s: %v", device.ID(), err)) + } + + err = device.SetAnimationScales(devices.AnimationScales{ + Window: req.Window, + Transition: req.Transition, + Animator: req.Animator, + }) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to set animation scales: %v", err)) + } + + return NewSuccessResponse(AnimationScalesResponse{ + Window: req.Window, + Transition: req.Transition, + Animator: req.Animator, + }) +} diff --git a/devices/android.go b/devices/android.go index 59b1653..aae4373 100644 --- a/devices/android.go +++ b/devices/android.go @@ -1376,6 +1376,53 @@ func (d *AndroidDevice) SetOrientation(orientation string) error { return nil } +// GetAnimationScales reads the current values of all three global animation +// scales. Callers should save the result and pass it to SetAnimationScales to +// restore the original values rather than assuming they were 1. +func (d *AndroidDevice) GetAnimationScales() (AnimationScales, error) { + keys := []string{"window_animation_scale", "transition_animation_scale", "animator_duration_scale"} + values := make([]float64, len(keys)) + + for i, key := range keys { + out, err := d.runAdbCommand("shell", "settings", "get", "global", key) + if err != nil { + return AnimationScales{}, fmt.Errorf("failed to get %s: %v", key, err) + } + val, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) + if err != nil { + return AnimationScales{}, fmt.Errorf("failed to parse %s value %q: %v", key, strings.TrimSpace(string(out)), err) + } + values[i] = val + } + + return AnimationScales{ + Window: values[0], + Transition: values[1], + Animator: values[2], + }, nil +} + +// SetAnimationScales writes the three global animation scales. +// Pass the result of GetAnimationScales to restore original values. +func (d *AndroidDevice) SetAnimationScales(scales AnimationScales) error { + entries := []struct { + key string + value float64 + }{ + {"window_animation_scale", scales.Window}, + {"transition_animation_scale", scales.Transition}, + {"animator_duration_scale", scales.Animator}, + } + + for _, e := range entries { + _, err := d.runAdbCommand("shell", "settings", "put", "global", e.key, strconv.FormatFloat(e.value, 'f', -1, 64)) + if err != nil { + return fmt.Errorf("failed to set %s: %v", e.key, err) + } + } + return nil +} + func (d *AndroidDevice) getCrashLog() (string, error) { output, err := d.runAdbCommand("logcat", "-b", "crash", "-d", "-v", "year") if err != nil { diff --git a/devices/common.go b/devices/common.go index 75f0565..1a5a31e 100644 --- a/devices/common.go +++ b/devices/common.go @@ -126,10 +126,20 @@ type ControllableDevice interface { DumpSourceRaw() (any, error) GetOrientation() (string, error) SetOrientation(orientation string) error + GetAnimationScales() (AnimationScales, error) + SetAnimationScales(scales AnimationScales) error ListCrashReports() ([]CrashReport, error) GetCrashReport(id string) ([]byte, error) } +// AnimationScales holds the three Android global animation scale values. +// A value of 0 disables the animation; 1 is the system default. +type AnimationScales struct { + Window float64 `json:"window"` + Transition float64 `json:"transition"` + Animator float64 `json:"animator"` +} + // GetAllControllableDevices aggregates all known devices with options func GetAllControllableDevices(includeOffline bool) ([]ControllableDevice, error) { diff --git a/devices/ios.go b/devices/ios.go index d6deda1..67afa7c 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -1142,6 +1142,14 @@ func (d IOSDevice) SetOrientation(orientation string) error { return d.wdaClient.SetOrientation(orientation) } +func (d IOSDevice) GetAnimationScales() (AnimationScales, error) { + return AnimationScales{}, fmt.Errorf("animation scales are not supported on iOS") +} + +func (d IOSDevice) SetAnimationScales(_ AnimationScales) error { + return fmt.Errorf("animation scales are not supported on iOS") +} + // DeviceKitInfo contains information about the started DeviceKit session type DeviceKitInfo struct { HTTPPort int `json:"httpPort"` diff --git a/devices/remote.go b/devices/remote.go index aff581d..13b3cd8 100644 --- a/devices/remote.go +++ b/devices/remote.go @@ -181,6 +181,22 @@ func (r *RemoteDevice) SetOrientation(orientation string) error { return r.fireRPC("device.io.orientation.set", params{"orientation": orientation}) } +func (r *RemoteDevice) GetAnimationScales() (AnimationScales, error) { + resp, err := rpcCall[AnimationScales](r, "device.io.animation-scales.get", params{}) + if err != nil { + return AnimationScales{}, err + } + return resp, nil +} + +func (r *RemoteDevice) SetAnimationScales(scales AnimationScales) error { + return r.fireRPC("device.io.animation-scales.set", params{ + "window": scales.Window, + "transition": scales.Transition, + "animator": scales.Animator, + }) +} + func (r *RemoteDevice) Info() (*FullDeviceInfo, error) { return rpcCall[*FullDeviceInfo](r, "device.info", params{}) } diff --git a/devices/simulator.go b/devices/simulator.go index 433566e..680d45a 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -857,6 +857,14 @@ func (s SimulatorDevice) SetOrientation(orientation string) error { return s.wdaClient.SetOrientation(orientation) } +func (s SimulatorDevice) GetAnimationScales() (AnimationScales, error) { + return AnimationScales{}, fmt.Errorf("animation scales are not supported on iOS simulator") +} + +func (s SimulatorDevice) SetAnimationScales(_ AnimationScales) error { + return fmt.Errorf("animation scales are not supported on iOS simulator") +} + var diagnosticReportsDir = filepath.Join(os.Getenv("HOME"), "Library", "Logs", "DiagnosticReports") func (s SimulatorDevice) ListCrashReports() ([]CrashReport, error) { diff --git a/server/dispatch.go b/server/dispatch.go index 8404435..2bbbc11 100644 --- a/server/dispatch.go +++ b/server/dispatch.go @@ -23,8 +23,10 @@ func GetMethodRegistry() map[string]HandlerFunc { "device.io.gesture": handleIoGesture, "device.url": handleURL, "device.info": handleDeviceInfo, - "device.io.orientation.get": handleIoOrientationGet, - "device.io.orientation.set": handleIoOrientationSet, + "device.io.orientation.get": handleIoOrientationGet, + "device.io.orientation.set": handleIoOrientationSet, + "device.io.animation-scales.get": handleIoAnimationScalesGet, + "device.io.animation-scales.set": handleIoAnimationScalesSet, "device.boot": handleDeviceBoot, "device.shutdown": handleDeviceShutdown, "device.reboot": handleDeviceReboot, diff --git a/server/server.go b/server/server.go index 84a0a89..5b9cba8 100644 --- a/server/server.go +++ b/server/server.go @@ -586,6 +586,17 @@ type IoOrientationSetParams struct { Orientation string `json:"orientation"` } +type IoAnimationScalesGetParams struct { + DeviceID string `json:"deviceId"` +} + +type IoAnimationScalesSetParams struct { + DeviceID string `json:"deviceId"` + Window float64 `json:"window"` + Transition float64 `json:"transition"` + Animator float64 `json:"animator"` +} + type DeviceBootParams struct { DeviceID string `json:"deviceId"` } @@ -784,6 +795,49 @@ func handleIoOrientationSet(params json.RawMessage) (any, error) { return okResponse, nil } +func handleIoAnimationScalesGet(params json.RawMessage) (any, error) { + if len(params) == 0 { + return nil, fmt.Errorf("'params' is required with fields: deviceId") + } + + var p IoAnimationScalesGetParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid parameters: %w. Expected fields: deviceId", err) + } + + response := commands.AnimationScalesGetCommand(commands.AnimationScalesGetRequest{ + DeviceID: p.DeviceID, + }) + if response.Status == "error" { + return nil, fmt.Errorf("%s", response.Error) + } + + return response.Data, nil +} + +func handleIoAnimationScalesSet(params json.RawMessage) (any, error) { + if len(params) == 0 { + return nil, fmt.Errorf("'params' is required with fields: deviceId, window, transition, animator") + } + + var p IoAnimationScalesSetParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid parameters: %w. Expected fields: deviceId, window, transition, animator", err) + } + + response := commands.AnimationScalesSetCommand(commands.AnimationScalesSetRequest{ + DeviceID: p.DeviceID, + Window: p.Window, + Transition: p.Transition, + Animator: p.Animator, + }) + if response.Status == "error" { + return nil, fmt.Errorf("%s", response.Error) + } + + return okResponse, nil +} + func handleDeviceBoot(params json.RawMessage) (any, error) { if len(params) == 0 { return nil, fmt.Errorf("'params' is required with fields: deviceId") From 457bf47a6eb52f7a5fcfe86065f88a706b5b3ed0 Mon Sep 17 00:00:00 2001 From: "farhan.labib" Date: Tue, 12 May 2026 17:33:45 +0600 Subject: [PATCH 2/2] docs: add missing docstrings to satisfy coverage threshold --- devices/ios.go | 2 ++ devices/remote.go | 2 ++ devices/simulator.go | 2 ++ server/server.go | 2 ++ 4 files changed, 8 insertions(+) diff --git a/devices/ios.go b/devices/ios.go index 67afa7c..b87bd7e 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -1142,10 +1142,12 @@ func (d IOSDevice) SetOrientation(orientation string) error { return d.wdaClient.SetOrientation(orientation) } +// GetAnimationScales is not supported on iOS devices. func (d IOSDevice) GetAnimationScales() (AnimationScales, error) { return AnimationScales{}, fmt.Errorf("animation scales are not supported on iOS") } +// SetAnimationScales is not supported on iOS devices. func (d IOSDevice) SetAnimationScales(_ AnimationScales) error { return fmt.Errorf("animation scales are not supported on iOS") } diff --git a/devices/remote.go b/devices/remote.go index 13b3cd8..b8fecd5 100644 --- a/devices/remote.go +++ b/devices/remote.go @@ -181,6 +181,7 @@ func (r *RemoteDevice) SetOrientation(orientation string) error { return r.fireRPC("device.io.orientation.set", params{"orientation": orientation}) } +// GetAnimationScales retrieves the current animation scale values from the remote device. func (r *RemoteDevice) GetAnimationScales() (AnimationScales, error) { resp, err := rpcCall[AnimationScales](r, "device.io.animation-scales.get", params{}) if err != nil { @@ -189,6 +190,7 @@ func (r *RemoteDevice) GetAnimationScales() (AnimationScales, error) { return resp, nil } +// SetAnimationScales sets the animation scale values on the remote device. func (r *RemoteDevice) SetAnimationScales(scales AnimationScales) error { return r.fireRPC("device.io.animation-scales.set", params{ "window": scales.Window, diff --git a/devices/simulator.go b/devices/simulator.go index 680d45a..729ba24 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -857,10 +857,12 @@ func (s SimulatorDevice) SetOrientation(orientation string) error { return s.wdaClient.SetOrientation(orientation) } +// GetAnimationScales is not supported on iOS simulators. func (s SimulatorDevice) GetAnimationScales() (AnimationScales, error) { return AnimationScales{}, fmt.Errorf("animation scales are not supported on iOS simulator") } +// SetAnimationScales is not supported on iOS simulators. func (s SimulatorDevice) SetAnimationScales(_ AnimationScales) error { return fmt.Errorf("animation scales are not supported on iOS simulator") } diff --git a/server/server.go b/server/server.go index 5b9cba8..89dcbca 100644 --- a/server/server.go +++ b/server/server.go @@ -795,6 +795,7 @@ func handleIoOrientationSet(params json.RawMessage) (any, error) { return okResponse, nil } +// handleIoAnimationScalesGet handles the device.io.animation-scales.get RPC method. func handleIoAnimationScalesGet(params json.RawMessage) (any, error) { if len(params) == 0 { return nil, fmt.Errorf("'params' is required with fields: deviceId") @@ -815,6 +816,7 @@ func handleIoAnimationScalesGet(params json.RawMessage) (any, error) { return response.Data, nil } +// handleIoAnimationScalesSet handles the device.io.animation-scales.set RPC method. func handleIoAnimationScalesSet(params json.RawMessage) (any, error) { if len(params) == 0 { return nil, fmt.Errorf("'params' is required with fields: deviceId, window, transition, animator")