blob: 9b9d4ad00b50114213cd4278a75c947442dc44f1 [file] [log] [blame]
// Copyright 2018 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package ui allows interacting with Android apps by Android UI Automator API.
// We use android-uiautomator-server, a JSON-RPC server running as an Android app,
// to invoke UI Automator methods remotely.
package ui
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"time"
"chromiumos/tast/common/action"
"chromiumos/tast/common/android/adb"
"chromiumos/tast/common/testexec"
"chromiumos/tast/errors"
"chromiumos/tast/testing"
)
const (
// StartTimeout is the timeout of NewDevice.
StartTimeout = 120 * time.Second
serverPackage = "com.github.uiautomator.test"
serverActivity = "androidx.test.runner.AndroidJUnitRunner"
)
var apkPaths = []string{
"/usr/local/share/android-uiautomator-server/app-uiautomator.apk",
"/usr/local/share/android-uiautomator-server/app-uiautomator-test.apk",
}
// Device provides access to state information about the Android system.
//
// Close must be called to clean up resources when a test is over.
//
// This object corresponds to UiDevice in UI Automator API:
// https://developer.android.com/reference/android/support/test/uiautomator/UiDevice
type Device struct {
hostDevice *adb.Device
hostPort int
sp *testexec.Cmd // Server process
debug bool
}
type jsonRPCRequest struct {
Version string `json:"jsonrpc"`
Method string `json:"method"`
ID int `json:"id"`
Params []interface{} `json:"params,omitempty"`
}
type jsonRPCError struct {
Message string `json:"message"`
}
type jsonRPCResponse struct {
Version string `json:"jsonrpc"`
Result json.RawMessage `json:"result"`
Error *jsonRPCError `json:"error"`
}
// DeviceInfo holds information about the device. See:
// https://github.com/xiaocong/android-uiautomator-server/blob/master/app/src/androidTest/java/com/github/uiautomator/stub/DeviceInfo.java // nocheck
type DeviceInfo struct {
CurrentPackagename string `json:"currentPackagename"`
DisplayWidth int `json:"displayWidth"`
DisplayHeight int `json:"displayHeight"`
DisplayRotation int `json:"displayRotation"`
DisplaySizeDpX int `json:"displaySizeDpX"`
DisplaySizeDpY int `json:"displaySizeDpY"`
ProductName string `json:"productName"`
NaturalOrientation bool `json:"naturalOrientation"`
ScreenOn bool `json:"screenOn"`
SDKInt int `json:"sdkInt"`
}
// NewDevice creates a Device object by starting and connecting to UI Automator server.
// Close must be called to clean up resources when a test is over.
func NewDevice(ctx context.Context, d *adb.Device) (*Device, error) {
ictx, cancel := context.WithTimeout(ctx, StartTimeout)
defer cancel()
testing.ContextLog(ctx, "Starting UI Automator server")
if err := installServer(ictx, d); err != nil {
return nil, err
}
sp := d.ShellCommand(ctx, "am", "instrument", "-w", serverPackage+"/"+serverActivity)
if err := sp.Start(); err != nil {
return nil, errors.Wrap(err, "failed starting UI Automator server")
}
androidPort := 9008
hostPort, err := d.ForwardTCP(ictx, androidPort)
if err != nil {
return nil, errors.Wrap(err, "failed to forward UI Automator port to host")
}
s := &Device{d, hostPort, sp, false}
if err := s.waitServer(ictx); err != nil {
s.Close(ctx)
return nil, errors.Wrap(err, "UI Automator server did not come up")
}
return s, nil
}
// NewDeviceWithRetry creates a Device object by starting and connecting to UI Automator server.
// Retries, in case of an error.
// Close must be called to clean up resources when a test is over.
func NewDeviceWithRetry(ctx context.Context, d *adb.Device) (*Device, error) {
var device *Device
var err error
maxAttempts := 2
err = action.Retry(maxAttempts, func(ctx context.Context) error {
device, err = NewDevice(ctx, d)
return err
}, 0)(ctx)
return device, err
}
// installServer installs UI Automator server to Android system.
func installServer(ctx context.Context, d *adb.Device) error {
for _, p := range apkPaths {
if err := d.Install(ctx, p); err != nil {
return errors.Wrapf(err, "failed installing %s", p)
}
}
return nil
}
// waitServer waits for UI Automator server to come up.
func (d *Device) waitServer(ctx context.Context) error {
for {
if ok := func() bool {
ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
return d.Alive(ctx)
}(); ok {
break
}
if err := testing.Sleep(ctx, 100*time.Millisecond); err != nil {
return err
}
}
return nil
}
// Alive returns true if UI Automator server is responding.
func (d *Device) Alive(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var res string
return d.call(ctx, "ping", &res) == nil && res == "pong"
}
// EnableDebug enables verbose RPC logging.
func (d *Device) EnableDebug() {
d.debug = true
}
// Close releases resources associated with d.
func (d *Device) Close(ctx context.Context) error {
// Request to stop the remote server.
if err := d.callStop(ctx); err != nil {
testing.ContextLog(ctx, "Failed to stop UI Automator server: ", err)
}
if err := d.hostDevice.RemoveForwardTCP(ctx, d.hostPort); err != nil {
testing.ContextLogf(ctx, "Failed to clean up UI Automator port(%d) for device(%v): %v", d.hostPort, d.hostDevice, err)
}
d.sp.Kill()
return d.sp.Wait()
}
// callStop calls a "stop" remote server method to gracefully shutdown the server.
func (d *Device) callStop(ctx context.Context) error {
res, err := http.Get(fmt.Sprintf("http://localhost:%d/stop", d.hostPort))
// The server closes the connection immediately, so getting EOF error here is expected.
if err != nil && err.(*url.Error).Err != io.EOF {
return err
}
if err == nil {
res.Body.Close()
}
return nil
}
// call calls a remote server method by JSON-RPC.
// method is a method name.
// out is a variable to store a returned result. If it is nil, results are discarded.
// params is a list of parameters to the remote method.
func (d *Device) call(ctx context.Context, method string, out interface{}, params ...interface{}) error {
// Prepare the request.
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/jsonrpc/0", d.hostPort), nil)
if err != nil {
return errors.Wrapf(err, "%s: failed initializing request", method)
}
req = req.WithContext(ctx)
reqData := jsonRPCRequest{
Version: "2.0",
Method: method,
Params: params,
}
reqBody, err := json.Marshal(&reqData)
if err != nil {
return errors.Wrapf(err, "%s: failed marshaling request", method)
}
req.Body = ioutil.NopCloser(bytes.NewReader(reqBody))
req.ContentLength = int64(len(reqBody))
req.Header.Add("Content-Type", "application/json")
req.Close = true
if d.debug {
testing.ContextLog(ctx, "-> ", string(reqBody))
}
// Send the request.
res, err := http.DefaultClient.Do(req)
if err != nil {
return errors.Wrap(err, method)
}
defer res.Body.Close()
// Status should be OK.
if res.StatusCode != 200 {
return errors.Errorf("%s: got status %d", method, res.StatusCode)
}
// Read and parse the response.
resBody, err := ioutil.ReadAll(res.Body)
if err != nil {
return errors.Wrapf(err, "%s: failed reading response", method)
}
if d.debug {
testing.ContextLog(ctx, "<- ", string(resBody))
}
var resData jsonRPCResponse
if err := json.Unmarshal(resBody, &resData); err != nil {
return errors.Wrapf(err, "%s: failed unmarshaling response", method)
}
// Check an error.
if resData.Error != nil {
return errors.Errorf("%s: %s", method, resData.Error.Message)
}
// If the caller does not need results, we can return now.
if out == nil {
return nil
}
// Parse the result.
if len(resData.Result) == 0 {
return errors.Errorf("%s: missing result", method)
}
if err := json.Unmarshal([]byte(resData.Result), out); err != nil {
testing.ContextLogf(ctx, "Failed unmarshaling to %T: %q", out, string(resData.Result))
return errors.Wrapf(err, "%s: failed unmarshaling result", method)
}
return nil
}
// WaitForIdle waits for the current application to idle.
// This method corresponds to UiDevice.waitForIdle(long).
// https://developer.android.com/reference/android/support/test/uiautomator/UiDevice.html#waitForIdle(long)
func (d *Device) WaitForIdle(ctx context.Context, timeout time.Duration) error {
return d.call(ctx, "waitForIdle", nil, timeout/time.Millisecond)
}
// WaitForWindowUpdate waits for a window content update event to occur.
// If a package name for the window is specified, but the current window does not have the same package name,
// the function returns eventOcurred=false, but no error is generated.
// Returns true if a window update occurred, false if timeout has elapsed or if the current window does not have the specified package name.
// This method corresponds to UiDevice.waitForWindowUpdate(string, long).
// https://developer.android.com/reference/android/support/test/uiautomator/UiDevice.html#waitforwindowupdate
func (d *Device) WaitForWindowUpdate(ctx context.Context, packageName string, timeout time.Duration) (eventOcurred bool, err error) {
if err := d.call(ctx, "waitForWindowUpdate", &eventOcurred, packageName, timeout/time.Millisecond); err != nil {
return false, err
}
return eventOcurred, nil
}
// PressKeyCode simulates a short press using a key code.
// keyCode is the key code. metaState represents the meta keys. Each bit represents a pressed meta key.
// This method corresponds to UiDevice.pressKeyCode(int, int)
// https://developer.android.com/reference/android/support/test/uiautomator/UiDevice#presskeycode
func (d *Device) PressKeyCode(ctx context.Context, keyCode KeyCode, metaState MetaState) error {
var success bool
if err := d.call(ctx, "pressKeyCode", &success, keyCode, metaState); err != nil {
return err
}
if !success {
return errors.Errorf("failed to press keycode=%d, meta=%#x", keyCode, metaState)
}
return nil
}
// GetInfo returns the device info.
// This method corresponds to the com.github.uiautomator.stub.getDeviceInfo().
// https://github.com/xiaocong/android-uiautomator-server/blob/master/app/src/androidTest/java/com/github/uiautomator/stub/DeviceInfo.java // nocheck
func (d *Device) GetInfo(ctx context.Context) (*DeviceInfo, error) {
var info DeviceInfo
if err := d.call(ctx, "deviceInfo", &info); err != nil {
return nil, err
}
return &info, nil
}
// Click performs a click at arbitrary coordinates specified by the user.
// This method corresponds to UiDevice.click(int, int)
// https://developer.android.com/reference/android/support/test/uiautomator/UiDevice.html#click
func (d *Device) Click(ctx context.Context, x, y int) error {
var success bool
if err := d.call(ctx, "click", &success, x, y); err != nil {
return err
}
if !success {
return errors.Errorf("failed to click (%d,%d)", x, y)
}
return nil
}