6 Commits

Author SHA1 Message Date
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
e87a194267 ci: add GitHub Actions test workflow 2026-02-23 18:07:29 +00:00
2b1116d920 refactor: remove unused const and var to pass staticcheck
- Remove unused 'killed' const in util.go
- Remove unused 'command' var in examples/injection/main.go
2026-02-23 18:07:29 +00:00
2a47181ccf chore(deps): update Go 1.16->1.26, update google/shlex 2026-02-23 18:07:29 +00:00
10 changed files with 296 additions and 60 deletions

17
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Test
on:
push:
branches: [master]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go test -race ./...
- run: go vet ./...
- run: go build ./...

43
adb.go
View File

@@ -127,11 +127,11 @@ func (d Device) Disconnect(ctx context.Context) error {
return err 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 // 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 // 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 // refuse following connection attempts if you don't disconnect from them before
// calling this function. // calling this function.
@@ -148,71 +148,70 @@ func (d Device) Push(ctx context.Context, src, dest string) error {
if err != nil { if err != nil {
return err 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 { if err != nil {
return err return err
} }
if errcode != 0 { if errcode != 0 {
return ErrUnspecified return ErrUnspecified
} }
_, _ = stdout, stderr
// TODO check the return strings of the output to determine if the file copy succeeded
return nil 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 { 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) { if !errors.Is(err, os.ErrNotExist) {
return err 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 { if err != nil {
return err return err
} }
if errcode != 0 { if errcode != 0 {
return ErrUnspecified return ErrUnspecified
} }
_, _ = stdout, stderr
// TODO check the return strings of the output to determine if the file copy succeeded
return nil return nil
} }
// Attempts to reboot the device // Reboot attempts to reboot the device.
// //
// Once the device reboots, you must manually reconnect. // 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 { 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 { if err != nil {
return err return err
} }
if errcode != 0 { if errcode != 0 {
return ErrUnspecified return ErrUnspecified
} }
_, _ = stdout, stderr
// TODO check the return strings of the output to determine if the file copy succeeded
return nil 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. // Note, this may not be possible on most devices.
// Returns an error if it can't be done. // Returns an error if it can't be done.
// The device connection will stay established. // The device connection will stay established.
// Once adb is relaunched as root, it will stay root until rebooted. // 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) { 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 { if err != nil {
return false, err return false, err
} }
if errcode != 0 { if errcode != 0 {
return false, ErrUnspecified return false, ErrUnspecified
} }
_, _ = stdout, stderr if strings.Contains(stdout, "adbd is already running as root") ||
// TODO check the return strings of the output to determine if the file copy succeeded strings.Contains(stdout, "restarting adbd as root") {
return true, nil return true, nil
} }
return false, nil
}

View File

@@ -1,8 +1,10 @@
package adb package adb
import ( import (
"net"
"reflect" "reflect"
"testing" "testing"
"time"
) )
func Test_parseDevices(t *testing.T) { func Test_parseDevices(t *testing.T) {
@@ -48,6 +50,33 @@ HT75R0202681 unauthorized`},
{IsAuthorized: false, SerialNo: "HT75R0202681"}, {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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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

@@ -5,12 +5,18 @@ import (
) )
var ( var (
// When an execution should have data but has none, but the exact error is // ErrStdoutEmpty is returned when an execution should have data but has none.
// indeterminite, this error is returned
ErrStdoutEmpty = errors.New("stdout expected to contain data but was empty") 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") 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") ErrCoordinatesNotFound = errors.New("coordinates for an input event are missing")
// ErrConnUSB is returned when connect/disconnect is called on a USB device.
ErrConnUSB = errors.New("cannot call connect/disconnect to device using USB") 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") ErrResolutionParseFail = errors.New("failed to parse screen size from input text")
// ErrDestExists is returned when a pull destination file already exists.
ErrDestExists = errors.New("destination file already exists")
// ErrUnspecified is returned when the exact error cannot be determined.
ErrUnspecified = errors.New("an unknown error has occurred, please open an issue on GitHub") ErrUnspecified = errors.New("an unknown error has occurred, please open an issue on GitHub")
) )

View File

@@ -7,13 +7,6 @@ import (
"github.com/taigrr/adb" "github.com/taigrr/adb"
) )
var command string
func init() {
// TODO allow for any input to be used as the command
command = "ls"
}
func main() { func main() {
ctx := context.TODO() ctx := context.TODO()
devs, err := adb.Devices(ctx) devs, err := adb.Devices(ctx)

2
go.mod
View File

@@ -1,5 +1,5 @@
module github.com/taigrr/adb module github.com/taigrr/adb
go 1.16 go 1.26.1
require github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 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. // you require functionality not provided by the exposed functions here.
// Instead of using Shell, please consider submitting a PR with the functionality // Instead of using Shell, please consider submitting a PR with the functionality
// you require. // 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) cmd, err := shlex.Split(command)
if err != nil { if err != nil {
return "", "", 1, err return "", "", 1, err
} }
prefix := []string{"-s", string(d.SerialNo), "shell"} prefix := []string{"-s", string(d.SerialNo), "shell"}
cmd = append(prefix, cmd...) cmd = append(prefix, cmd...)
stdout, stderr, errcode, err := execute(ctx, cmd) return execute(ctx, cmd)
return stdout, stderr, errcode, err
} }
// adb shell wm size // 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 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: "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}, {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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

37
util.go
View File

@@ -4,18 +4,18 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"log"
"os/exec" "os/exec"
"sync"
) )
var adb string var (
adb string
adbOnce sync.Once
)
const killed = 130 func findADB() {
func init() {
path, err := exec.LookPath("adb") path, err := exec.LookPath("adb")
if err != nil { if err != nil {
log.Printf("%v", ErrNotInstalled)
adb = "" adb = ""
return return
} }
@@ -23,25 +23,24 @@ func init() {
} }
func execute(ctx context.Context, args []string) (string, string, int, error) { func execute(ctx context.Context, args []string) (string, string, int, error) {
var ( adbOnce.Do(findADB)
err error
stderr bytes.Buffer
stdout bytes.Buffer
code int
output string
warnings string
)
if adb == "" { if adb == "" {
panic(ErrNotInstalled) return "", "", -1, ErrNotInstalled
} }
var (
stderr bytes.Buffer
stdout bytes.Buffer
)
cmd := exec.CommandContext(ctx, adb, args...) cmd := exec.CommandContext(ctx, adb, args...)
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
err = cmd.Run() err := cmd.Run()
output = stdout.String() output := stdout.String()
warnings = stderr.String() warnings := stderr.String()
code = cmd.ProcessState.ExitCode() code := cmd.ProcessState.ExitCode()
customErr := filterErr(warnings) customErr := filterErr(warnings)
if customErr != nil { if customErr != nil {

24
util_test.go Normal file
View File

@@ -0,0 +1,24 @@
package adb
import (
"testing"
)
func Test_filterErr(t *testing.T) {
tests := []struct {
name string
stderr string
wantErr bool
}{
{name: "empty stderr", stderr: "", wantErr: false},
{name: "random output", stderr: "some warning text", wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := filterErr(tt.stderr)
if (err != nil) != tt.wantErr {
t.Errorf("filterErr() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}