4 Commits

Author SHA1 Message Date
747c4abb7f fix(errors): implement filterErr with common adb stderr patterns
- Implement filterErr to detect device-not-found, offline, unauthorized,
  connection-refused, and multiple-device errors from stderr output
- Add ErrDeviceNotFound, ErrDeviceOffline, ErrDeviceUnauthorized,
  ErrConnectionRefused, ErrMoreThanOneDevice sentinel errors
- Remove log.Printf in CaptureSequence (was marked TODO for removal)
- Fix ConnString to use local variable instead of mutating value receiver
- Update go directive to 1.26.2
- Update README: check implemented features, fix intro typo, add
  root/keyevent/getevent to supported functions list
- Expand filterErr tests for all new error patterns
2026-04-08 10:38:56 +00:00
80d01ae3e9 Merge pull request #2 from taigrr/cd/tests-quality-fixes
fix: replace panic with error, fix device targeting, add tests
2026-03-14 22:49:58 -04:00
9ed4d3ef4f fix: replace panic with error return in execute(), fix Pull/Push/Reboot/Root targeting
- execute() no longer panics when adb is not found; returns ErrNotInstalled
- Use sync.Once for lazy adb path lookup instead of init()
- Fix Pull() checking src (device path) instead of dest (local path)
- Add ErrDestExists error for Pull destination conflicts
- Add -s serial flag to Push, Pull, Reboot, Root for multi-device support
- Root() now parses stdout to determine actual success
- Fix Shell() redundant variable redeclaration
- Fix 'inconsostency' typo in KillServer doc
- Add doc comments to all error variables
- Update Go 1.26.0 -> 1.26.1
- Add tests: ConnString, ShortenSleep, GetLength, JSON roundtrip,
  SequenceImporter.ToInput, insertSleeps, parseDevices edge cases,
  parseScreenResolution edge cases, filterErr
2026-03-14 06:33:10 +00:00
f11c4f45d7 Merge pull request #1 from taigrr/cd/modernize
chore: modernize Go 1.16→1.26, fix staticcheck, add CI
2026-02-23 14:40:59 -05:00
10 changed files with 334 additions and 73 deletions

View File

@@ -4,8 +4,8 @@
This library aims at providing idiomatic `adb` bindings for go developers, in order to make it easier to write system tooling using golang.
This tool tries to take guesswork out of arbitrarily shelling out to `adb` by providing a structured, thoroughly-tested wrapper for the `adb` functions most-likely to be used in a system program.
If `adb` must be installed in your path `PATH`. At this time, while this library may work on Windows or MacOS, only Linux is supported.
If you would like to add support for Windows, MacOS, *BSD..., please [Submit a Pull Request](https://github.com/taigrr/adb/pulls).
`adb` must be installed and available in your `PATH`. At this time, while this library may work on Windows or macOS, only Linux is supported.
If you would like to add support for Windows, macOS, *BSD, etc., please [Submit a Pull Request](https://github.com/taigrr/adb/pulls).
## What is adb
@@ -14,17 +14,21 @@ If you would like to add support for Windows, MacOS, *BSD..., please [Submit a P
## Supported adb functions
- [ ] `adb connect`
- [ ] `adb disconnect`
- [ ] `adb shell <input>s`
- [ ] `adb kill-server`
- [ ] `adb device`
- [ ] `adb pull`
- [x] `adb connect`
- [x] `adb disconnect`
- [x] `adb shell <command>`
- [x] `adb kill-server`
- [x] `adb devices`
- [x] `adb pull`
- [ ] `adb install`
- [ ] `adb push`
- [ ] `adb reboot`
- [ ] `adb shell input tap X Y`
- [ ] `adb shell input swipe X1 Y1 X2 Y2 duration`
- [x] `adb push`
- [x] `adb reboot`
- [x] `adb root`
- [x] `adb shell input tap X Y`
- [x] `adb shell input swipe X1 Y1 X2 Y2 duration`
- [x] `adb shell input keyevent` (home, back, app switch)
- [x] `adb shell wm size` (screen resolution)
- [x] `adb shell getevent` (capture and replay tap sequences)
Please note that as this is a purpose-driven project library, not all functionality of ADB is currently supported, but if you need functionality that's not currently supported,
Feel free to [Open an Issue](https://github.com/taigrr/adb/issues) or [Submit a Pull Request](https://github.com/taigrr/adb/pulls)

52
adb.go
View File

@@ -51,10 +51,11 @@ func Connect(ctx context.Context, opts ConnOptions) (Device, error) {
}
func (d Device) ConnString() string {
if d.Port == 0 {
d.Port = 5555
port := d.Port
if port == 0 {
port = 5555
}
return d.IP.String() + ":" + strconv.Itoa(int(d.Port))
return d.IP.String() + ":" + strconv.Itoa(int(port))
}
// Connect to a previously discovered device.
@@ -127,11 +128,11 @@ func (d Device) Disconnect(ctx context.Context) error {
return err
}
// Kill the ADB Server
// KillServer kills the ADB server.
//
// Warning, this function call may cause inconsostency if not used properly.
// Warning: this function call may cause inconsistency if not used properly.
// Killing the ADB server shouldn't ever technically be necessary, but if you do
// decide to use this function. note that it may invalidate all existing device structs.
// decide to use this function, note that it may invalidate all existing device structs.
// Older versions of Android don't play nicely with kill-server, and some may
// refuse following connection attempts if you don't disconnect from them before
// calling this function.
@@ -148,71 +149,70 @@ func (d Device) Push(ctx context.Context, src, dest string) error {
if err != nil {
return err
}
stdout, stderr, errcode, err := execute(ctx, []string{"push", src, dest})
_, _, errcode, err := execute(ctx, []string{"-s", string(d.SerialNo), "push", src, dest})
if err != nil {
return err
}
if errcode != 0 {
return ErrUnspecified
}
_, _ = stdout, stderr
// TODO check the return strings of the output to determine if the file copy succeeded
return nil
}
// Pulls a file from a Device
// Pull a file from a Device.
//
// Returns an error if src does not exist, or if dest already exists or cannot be created
// Returns an error if dest already exists or the file cannot be pulled.
func (d Device) Pull(ctx context.Context, src, dest string) error {
_, err := os.Stat(src)
_, err := os.Stat(dest)
if err == nil {
return ErrDestExists
}
if !errors.Is(err, os.ErrNotExist) {
return err
}
stdout, stderr, errcode, err := execute(ctx, []string{"pull", src, dest})
_, _, errcode, err := execute(ctx, []string{"-s", string(d.SerialNo), "pull", src, dest})
if err != nil {
return err
}
if errcode != 0 {
return ErrUnspecified
}
_, _ = stdout, stderr
// TODO check the return strings of the output to determine if the file copy succeeded
return nil
}
// Attempts to reboot the device
// Reboot attempts to reboot the device.
//
// Once the device reboots, you must manually reconnect.
// Returns an error if the device cannot be contacted
// Returns an error if the device cannot be contacted.
func (d Device) Reboot(ctx context.Context) error {
stdout, stderr, errcode, err := execute(ctx, []string{"reboot"})
_, _, errcode, err := execute(ctx, []string{"-s", string(d.SerialNo), "reboot"})
if err != nil {
return err
}
if errcode != 0 {
return ErrUnspecified
}
_, _ = stdout, stderr
// TODO check the return strings of the output to determine if the file copy succeeded
return nil
}
// Attempt to relaunch adb as root on the Device.
// Root attempts to relaunch adb as root on the Device.
//
// Note, this may not be possible on most devices.
// Returns an error if it can't be done.
// The device connection will stay established.
// Once adb is relaunched as root, it will stay root until rebooted.
// returns true if the device successfully relaunched as root
// Returns true if the device successfully relaunched as root.
func (d Device) Root(ctx context.Context) (success bool, err error) {
stdout, stderr, errcode, err := execute(ctx, []string{"root"})
stdout, _, errcode, err := execute(ctx, []string{"-s", string(d.SerialNo), "root"})
if err != nil {
return false, err
}
if errcode != 0 {
return false, ErrUnspecified
}
_, _ = stdout, stderr
// TODO check the return strings of the output to determine if the file copy succeeded
return true, nil
if strings.Contains(stdout, "adbd is already running as root") ||
strings.Contains(stdout, "restarting adbd as root") {
return true, nil
}
return false, nil
}

View File

@@ -1,8 +1,10 @@
package adb
import (
"net"
"reflect"
"testing"
"time"
)
func Test_parseDevices(t *testing.T) {
@@ -48,6 +50,33 @@ HT75R0202681 unauthorized`},
{IsAuthorized: false, SerialNo: "HT75R0202681"},
},
},
{
name: "empty string",
args: args{stdout: ""},
wantErr: false,
want: []Device{},
},
{
name: "offline device",
args: args{stdout: `List of devices attached
ABCD1234 offline`},
wantErr: false,
want: []Device{
{IsAuthorized: false, SerialNo: "ABCD1234"},
},
},
{
name: "extra whitespace lines",
args: args{stdout: `List of devices attached
19291FDEE0023W device
`},
wantErr: false,
want: []Device{
{IsAuthorized: true, SerialNo: "19291FDEE0023W"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -62,3 +91,171 @@ HT75R0202681 unauthorized`},
})
}
}
func TestDevice_ConnString(t *testing.T) {
tests := []struct {
name string
dev Device
want string
}{
{
name: "default port",
dev: Device{IP: net.IPAddr{IP: net.ParseIP("192.168.1.100")}},
want: "192.168.1.100:5555",
},
{
name: "custom port",
dev: Device{IP: net.IPAddr{IP: net.ParseIP("10.0.0.5")}, Port: 5556},
want: "10.0.0.5:5556",
},
{
name: "ipv6",
dev: Device{IP: net.IPAddr{IP: net.ParseIP("::1")}, Port: 5555},
want: "::1:5555",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.dev.ConnString()
if got != tt.want {
t.Errorf("ConnString() = %q, want %q", got, tt.want)
}
})
}
}
func TestTapSequence_ShortenSleep(t *testing.T) {
seq := TapSequence{
Events: []Input{
SequenceTap{X: 100, Y: 200, Type: SeqTap},
SequenceSleep{Duration: time.Second * 4, Type: SeqSleep},
SequenceTap{X: 300, Y: 400, Type: SeqTap},
},
}
shortened := seq.ShortenSleep(2)
if len(shortened.Events) != 3 {
t.Fatalf("expected 3 events, got %d", len(shortened.Events))
}
sleep, ok := shortened.Events[1].(SequenceSleep)
if !ok {
t.Fatal("expected second event to be SequenceSleep")
}
if sleep.Duration != time.Second*2 {
t.Errorf("expected sleep duration 2s, got %v", sleep.Duration)
}
}
func TestTapSequence_GetLength(t *testing.T) {
now := time.Now()
seq := TapSequence{
Events: []Input{
SequenceSleep{Duration: time.Second * 10, Type: SeqSleep},
SequenceSwipe{
X1: 0, Y1: 0, X2: 100, Y2: 100,
Start: now, End: now.Add(time.Second * 5),
Type: SeqSwipe,
},
},
}
got := seq.GetLength()
// 15s * 110/100 = 16.5s
want := time.Second * 15 * 110 / 100
if got != want {
t.Errorf("GetLength() = %v, want %v", got, want)
}
}
func TestTapSequence_JSONRoundTrip(t *testing.T) {
now := time.UnixMilli(1700000000000)
original := TapSequence{
Resolution: Resolution{Width: 1080, Height: 2340},
Events: []Input{
SequenceSwipe{
X1: 10, Y1: 20, X2: 30, Y2: 40,
Start: now, End: now.Add(time.Millisecond * 500),
Type: SeqSwipe,
},
},
}
jsonBytes := original.ToJSON()
roundTripped, err := TapSequenceFromJSON(jsonBytes)
if err != nil {
t.Fatalf("TapSequenceFromJSON() error = %v", err)
}
if roundTripped.Resolution != original.Resolution {
t.Errorf("Resolution mismatch: got %v, want %v", roundTripped.Resolution, original.Resolution)
}
if len(roundTripped.Events) != len(original.Events) {
t.Fatalf("Events length mismatch: got %d, want %d", len(roundTripped.Events), len(original.Events))
}
}
func TestSequenceImporter_ToInput(t *testing.T) {
now := time.UnixMilli(1700000000000)
tests := []struct {
name string
importer SequenceImporter
wantType SeqType
}{
{
name: "sleep",
importer: SequenceImporter{Type: SeqSleep, Duration: time.Second},
wantType: SeqSleep,
},
{
name: "tap",
importer: SequenceImporter{Type: SeqTap, X: 10, Y: 20, Start: now, End: now},
wantType: SeqTap,
},
{
name: "swipe",
importer: SequenceImporter{Type: SeqSwipe, X1: 10, Y1: 20, X2: 30, Y2: 40, Start: now, End: now.Add(time.Second)},
wantType: SeqSwipe,
},
{
name: "unknown defaults to sleep",
importer: SequenceImporter{Type: SeqType(99)},
wantType: SeqSleep,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := tt.importer.ToInput()
switch tt.wantType {
case SeqSleep:
if _, ok := input.(SequenceSleep); !ok {
t.Errorf("expected SequenceSleep, got %T", input)
}
case SeqTap:
if _, ok := input.(SequenceTap); !ok {
t.Errorf("expected SequenceTap, got %T", input)
}
case SeqSwipe:
if _, ok := input.(SequenceSwipe); !ok {
t.Errorf("expected SequenceSwipe, got %T", input)
}
}
})
}
}
func TestInsertSleeps(t *testing.T) {
now := time.UnixMilli(1000)
inputs := []Input{
SequenceTap{X: 1, Y: 2, Start: now, End: now.Add(time.Millisecond * 100), Type: SeqTap},
SequenceTap{X: 3, Y: 4, Start: now.Add(time.Millisecond * 500), End: now.Add(time.Millisecond * 600), Type: SeqTap},
}
result := insertSleeps(inputs)
// Should be: tap, sleep, tap
if len(result) != 3 {
t.Fatalf("expected 3 events, got %d", len(result))
}
sleep, ok := result[1].(SequenceSleep)
if !ok {
t.Fatal("expected second event to be SequenceSleep")
}
// Sleep should be from end of first (100ms) to end of second (600ms) = 500ms
if sleep.Duration != time.Millisecond*500 {
t.Errorf("expected sleep duration 500ms, got %v", sleep.Duration)
}
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"log"
"regexp"
"strconv"
"strings"
@@ -236,10 +235,7 @@ func (d Device) CaptureSequence(ctx context.Context) (t TapSequence, err error)
if errors.Is(err, ErrUnspecified) {
err = nil
}
if errCode != 130 && errCode != -1 && errCode != 1 {
// TODO remove log output here
log.Printf("Expected error code 130 or -1, but got %d\n", errCode)
}
_ = errCode
if stdout == "" {
return TapSequence{}, ErrStdoutEmpty

View File

@@ -5,12 +5,28 @@ import (
)
var (
// When an execution should have data but has none, but the exact error is
// indeterminite, this error is returned
ErrStdoutEmpty = errors.New("stdout expected to contain data but was empty")
ErrNotInstalled = errors.New("adb is not installed or not in PATH")
// ErrStdoutEmpty is returned when an execution should have data but has none.
ErrStdoutEmpty = errors.New("stdout expected to contain data but was empty")
// ErrNotInstalled is returned when adb cannot be found in PATH.
ErrNotInstalled = errors.New("adb is not installed or not in PATH")
// ErrCoordinatesNotFound is returned when touch event coordinates are missing.
ErrCoordinatesNotFound = errors.New("coordinates for an input event are missing")
ErrConnUSB = errors.New("cannot call connect/disconnect to device using USB")
// ErrConnUSB is returned when connect/disconnect is called on a USB device.
ErrConnUSB = errors.New("cannot call connect/disconnect to device using USB")
// ErrResolutionParseFail is returned when screen resolution output cannot be parsed.
ErrResolutionParseFail = errors.New("failed to parse screen size from input text")
ErrUnspecified = errors.New("an unknown error has occurred, please open an issue on GitHub")
// ErrDestExists is returned when a pull destination file already exists.
ErrDestExists = errors.New("destination file already exists")
// ErrDeviceNotFound is returned when no device is connected or the target device cannot be found.
ErrDeviceNotFound = errors.New("device not found")
// ErrDeviceOffline is returned when the target device is offline.
ErrDeviceOffline = errors.New("device offline")
// ErrDeviceUnauthorized is returned when the device has not authorized USB debugging.
ErrDeviceUnauthorized = errors.New("device unauthorized; check the confirmation dialog on the device")
// ErrConnectionRefused is returned when the connection to a device is refused.
ErrConnectionRefused = errors.New("connection refused")
// ErrMoreThanOneDevice is returned when multiple devices are connected and no serial is specified.
ErrMoreThanOneDevice = errors.New("more than one device/emulator; use -s to specify a device")
// ErrUnspecified is returned when the exact error cannot be determined.
ErrUnspecified = errors.New("an unknown error has occurred, please open an issue on GitHub")
)

2
go.mod
View File

@@ -1,5 +1,5 @@
module github.com/taigrr/adb
go 1.26.0
go 1.26.2
require github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510

View File

@@ -15,15 +15,14 @@ import (
// you require functionality not provided by the exposed functions here.
// Instead of using Shell, please consider submitting a PR with the functionality
// you require.
func (d Device) Shell(ctx context.Context, command string) (stdout string, stderr string, ErrCode int, err error) {
func (d Device) Shell(ctx context.Context, command string) (stdout string, stderr string, errCode int, err error) {
cmd, err := shlex.Split(command)
if err != nil {
return "", "", 1, err
}
prefix := []string{"-s", string(d.SerialNo), "shell"}
cmd = append(prefix, cmd...)
stdout, stderr, errcode, err := execute(ctx, cmd)
return stdout, stderr, errcode, err
return execute(ctx, cmd)
}
// adb shell wm size

View File

@@ -18,7 +18,9 @@ func Test_parseScreenResolution(t *testing.T) {
{name: "Pixel 4XL", args: args{in: "Physical size: 1440x3040"}, wantRes: Resolution{Width: 1440, Height: 3040}, wantErr: false},
{name: "Pixel XL", args: args{in: "Physical size: 1440x2560"}, wantRes: Resolution{Width: 1440, Height: 2560}, wantErr: false},
{name: "garbage", args: args{in: "asdfhjkla"}, wantRes: Resolution{Width: -1, Height: -1}, wantErr: true},
// TODO: Add test cases.
{name: "empty string", args: args{in: ""}, wantRes: Resolution{Width: -1, Height: -1}, wantErr: true},
{name: "Samsung S21", args: args{in: "Physical size: 1080x2400"}, wantRes: Resolution{Width: 1080, Height: 2400}, wantErr: false},
{name: "override size", args: args{in: "Physical size: 1440x3120\nOverride size: 1080x2340"}, wantRes: Resolution{Width: 1440, Height: 3120}, wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

55
util.go
View File

@@ -4,16 +4,19 @@ import (
"bytes"
"context"
"fmt"
"log"
"os/exec"
"strings"
"sync"
)
var adb string
var (
adb string
adbOnce sync.Once
)
func init() {
func findADB() {
path, err := exec.LookPath("adb")
if err != nil {
log.Printf("%v", ErrNotInstalled)
adb = ""
return
}
@@ -21,25 +24,24 @@ func init() {
}
func execute(ctx context.Context, args []string) (string, string, int, error) {
var (
err error
stderr bytes.Buffer
stdout bytes.Buffer
code int
output string
warnings string
)
adbOnce.Do(findADB)
if adb == "" {
panic(ErrNotInstalled)
return "", "", -1, ErrNotInstalled
}
var (
stderr bytes.Buffer
stdout bytes.Buffer
)
cmd := exec.CommandContext(ctx, adb, args...)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
output = stdout.String()
warnings = stderr.String()
code = cmd.ProcessState.ExitCode()
err := cmd.Run()
output := stdout.String()
warnings := stderr.String()
code := cmd.ProcessState.ExitCode()
customErr := filterErr(warnings)
if customErr != nil {
@@ -55,7 +57,22 @@ func execute(ctx context.Context, args []string) (string, string, int, error) {
// filterErr matches known output strings against the stderr.
//
// The inferred error type is then returned.
// TODO: implement
func filterErr(stderr string) error {
return nil
if stderr == "" {
return nil
}
switch {
case strings.Contains(stderr, "device not found"):
return ErrDeviceNotFound
case strings.Contains(stderr, "device offline"):
return ErrDeviceOffline
case strings.Contains(stderr, "device unauthorized"):
return ErrDeviceUnauthorized
case strings.Contains(stderr, "Connection refused"):
return ErrConnectionRefused
case strings.Contains(stderr, "more than one device"):
return ErrMoreThanOneDevice
default:
return nil
}
}

30
util_test.go Normal file
View File

@@ -0,0 +1,30 @@
package adb
import (
"errors"
"testing"
)
func Test_filterErr(t *testing.T) {
tests := []struct {
name string
stderr string
wantErr error
}{
{name: "empty stderr", stderr: "", wantErr: nil},
{name: "random output", stderr: "some warning text", wantErr: nil},
{name: "device not found", stderr: "error: device not found", wantErr: ErrDeviceNotFound},
{name: "device offline", stderr: "error: device offline", wantErr: ErrDeviceOffline},
{name: "device unauthorized", stderr: "error: device unauthorized.\nThis adb server's $ADB_VENDOR_KEYS is not set", wantErr: ErrDeviceUnauthorized},
{name: "connection refused", stderr: "cannot connect to daemon at tcp:5037: Connection refused", wantErr: ErrConnectionRefused},
{name: "more than one device", stderr: "error: more than one device/emulator", wantErr: ErrMoreThanOneDevice},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := filterErr(tt.stderr)
if !errors.Is(err, tt.wantErr) {
t.Errorf("filterErr() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}