Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions acquisition/acquisition.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import (
"github.com/botherder/go-savetime/hashes"
"github.com/google/uuid"
"github.com/mvt-project/androidqf/adb"
"github.com/mvt-project/androidqf/assets"
"github.com/mvt-project/androidqf/log"
"github.com/mvt-project/androidqf/utils"
)
Expand Down Expand Up @@ -180,12 +179,6 @@ func (a *Acquisition) Complete() {
if a.Collector != nil {
a.Collector.Clean()
}

// Stop ADB server before trying to remove extracted assets
if adb.Client != nil {
adb.Client.KillServer()
}
assets.CleanAssets()
}

func (a *Acquisition) GetSystemInformation() error {
Expand Down
64 changes: 59 additions & 5 deletions adb/adb.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ type ADB struct {
Serial string
}

type DeviceInfo struct {
Serial string
State string
Product string
Model string
Device string
}

var Client *ADB

// New returns a new ADB instance.
Expand All @@ -32,9 +40,6 @@ func New() (*ADB, error) {
}
log.Debugf("ADB found at path: %s", adb.ExePath)

log.Debug("Killing existing ADB server if running")
adb.KillServer()

// Confirm that we can call "adb devices" without errors
_, err = adb.Devices()
if err != nil {
Expand Down Expand Up @@ -63,11 +68,10 @@ func (a *ADB) SetSerial(serial string) (string, error) {
}
a.Serial = serial
} else {
// Problem if multiple devices
if len(devices) > 1 {
return "", fmt.Errorf("multiple devices connected, please stop AndroidQF and provide a serial number")
}
a.Serial = ""
a.Serial = devices[0]
}
return a.Serial, nil
}
Expand All @@ -93,6 +97,56 @@ func (a *ADB) Devices() ([]string, error) {
return devices, nil
}

func (a *ADB) DeviceInfos() ([]DeviceInfo, error) {
var devices []DeviceInfo
out, err := exec.Command(a.ExePath, "devices", "-l").Output()
if err != nil {
return devices, fmt.Errorf("failed to use the adb executable: %v",
err)
}

lines := strings.Split(string(out), "\n")
for _, line := range lines[1:] {
info, ok := parseDeviceInfoLine(line)
if !ok {
continue
}
devices = append(devices, info)
log.Debug("Found new device: ", info.Serial)
}

return devices, nil
}

func parseDeviceInfoLine(line string) (DeviceInfo, bool) {
fields := strings.Fields(line)
if len(fields) < 2 {
return DeviceInfo{}, false
}

info := DeviceInfo{
Serial: fields[0],
State: fields[1],
}
for _, field := range fields[2:] {
key, value, ok := strings.Cut(field, ":")
if !ok {
continue
}

switch key {
case "product":
info.Product = value
case "model":
info.Model = value
case "device":
info.Device = value
}
}

return info, true
}

// Run a command to the given phone using exec
// Returns string and/or error
func (a *ADB) Exec(args ...string) ([]byte, error) {
Expand Down
112 changes: 112 additions & 0 deletions adb/adb_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package adb

import (
"fmt"
"os"
"strings"
"testing"
)

func TestMain(m *testing.M) {
if os.Getenv("ANDROIDQF_FAKE_ADB") == "1" {
fakeADB()
return
}
os.Exit(m.Run())
}

func fakeADB() {
if len(os.Args) < 2 {
os.Exit(2)
}

switch os.Args[1] {
case "devices":
fmt.Println("List of devices attached")
for _, device := range strings.Split(os.Getenv("ANDROIDQF_FAKE_ADB_DEVICES"), ",") {
device = strings.TrimSpace(device)
if device != "" {
if len(os.Args) > 2 && os.Args[2] == "-l" {
fmt.Printf("%s device product:fake model:%s_Model device:%s transport_id:1\n", device, device, device)
} else {
fmt.Printf("%s\tdevice\n", device)
}
}
}
default:
os.Exit(2)
}
}

func TestDeviceInfosParsesLongDeviceList(t *testing.T) {
client := newFakeADB(t, "device-1,device-2")
devices, err := client.DeviceInfos()
if err != nil {
t.Fatalf("DeviceInfos returned error: %v", err)
}
if len(devices) != 2 {
t.Fatalf("DeviceInfos returned %d devices, want 2", len(devices))
}
if devices[0].Serial != "device-1" {
t.Fatalf("first serial = %q, want device-1", devices[0].Serial)
}
if devices[0].Model != "device-1_Model" {
t.Fatalf("first model = %q, want device-1_Model", devices[0].Model)
}
}

func TestParseDeviceInfoLineUnauthorizedWithoutModel(t *testing.T) {
info, ok := parseDeviceInfoLine("5B221JEBF18336 unauthorized usb:336592896X transport_id:1")
if !ok {
t.Fatal("parseDeviceInfoLine returned ok=false")
}
if info.Serial != "5B221JEBF18336" {
t.Fatalf("serial = %q, want 5B221JEBF18336", info.Serial)
}
if info.State != "unauthorized" {
t.Fatalf("state = %q, want unauthorized", info.State)
}
if info.Model != "" {
t.Fatalf("model = %q, want empty", info.Model)
}
}

func newFakeADB(t *testing.T, devices string) *ADB {
t.Helper()
t.Setenv("ANDROIDQF_FAKE_ADB", "1")
t.Setenv("ANDROIDQF_FAKE_ADB_DEVICES", devices)
return &ADB{ExePath: os.Args[0]}
}

func TestSetSerialSingleDeviceUsesExplicitSerial(t *testing.T) {
client := newFakeADB(t, "device-1")
serial, err := client.SetSerial("")
if err != nil {
t.Fatalf("SetSerial returned error: %v", err)
}
if serial != "device-1" {
t.Fatalf("serial = %q, want device-1", serial)
}
if client.Serial != "device-1" {
t.Fatalf("client.Serial = %q, want device-1", client.Serial)
}
}

func TestSetSerialMultipleDevicesWithoutSerialErrors(t *testing.T) {
client := newFakeADB(t, "device-1,device-2")
_, err := client.SetSerial("")
if err == nil {
t.Fatal("SetSerial returned nil error, want multiple devices error")
}
}

func TestSetSerialExplicitSerial(t *testing.T) {
client := newFakeADB(t, "device-1,device-2")
serial, err := client.SetSerial("device-2")
if err != nil {
t.Fatalf("SetSerial returned error: %v", err)
}
if serial != "device-2" {
t.Fatalf("serial = %q, want device-2", serial)
}
}
139 changes: 139 additions & 0 deletions device_selection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package main

import (
"errors"
"testing"
"time"

"github.com/mvt-project/androidqf/adb"
)

func TestResolveADBSerialNoDevicesDoesNotPrompt(t *testing.T) {
called := false
serial, prompted, err := resolveADBSerial("", nil, func([]deviceMenuItem) (string, error) {
called = true
return "", nil
}, nil)
if err != nil {
t.Fatalf("resolveADBSerial returned error: %v", err)
}
if serial != "" {
t.Fatalf("serial = %q, want empty", serial)
}
if prompted {
t.Fatal("prompted = true, want false")
}
if called {
t.Fatal("selector was called for zero devices")
}
}

func TestResolveADBSerialSingleDeviceDoesNotPrompt(t *testing.T) {
called := false
serial, prompted, err := resolveADBSerial("", []adb.DeviceInfo{{Serial: "device-1"}}, func([]deviceMenuItem) (string, error) {
called = true
return "", nil
}, nil)
if err != nil {
t.Fatalf("resolveADBSerial returned error: %v", err)
}
if serial != "device-1" {
t.Fatalf("serial = %q, want device-1", serial)
}
if prompted {
t.Fatal("prompted = true, want false")
}
if called {
t.Fatal("selector was called for one device")
}
}

func TestResolveADBSerialMultipleDevicesPromptsWithRunningStatus(t *testing.T) {
started := time.Date(2026, 7, 7, 10, 11, 12, 0, time.UTC)
running := map[string]runningExtraction{
"device-2": {
Serial: "device-2",
PID: 1234,
Started: started,
},
}

var gotItems []deviceMenuItem
serial, prompted, err := resolveADBSerial("", []adb.DeviceInfo{
{Serial: "device-1", State: "device", Model: "Pixel_9a"},
{Serial: "device-2", State: "device", Model: "XQ_DC54"},
}, func(items []deviceMenuItem) (string, error) {
gotItems = items
return items[1].Serial, nil
}, running)
if err != nil {
t.Fatalf("resolveADBSerial returned error: %v", err)
}
if serial != "device-2" {
t.Fatalf("serial = %q, want device-2", serial)
}
if !prompted {
t.Fatal("prompted = false, want true")
}
if len(gotItems) != 2 {
t.Fatalf("selector got %d items, want 2", len(gotItems))
}
if gotItems[0].Status != "" {
t.Fatalf("first item status = %q, want empty", gotItems[0].Status)
}
if gotItems[0].Title != "Pixel 9a (device-1)" {
t.Fatalf("first item title = %q, want Pixel 9a (device-1)", gotItems[0].Title)
}
if gotItems[1].Status == "" {
t.Fatal("second item status is empty, want running extraction status")
}
}

func TestResolveADBSerialMultipleDevicesReturnsSelectorError(t *testing.T) {
wantErr := errors.New("selection failed")
serial, prompted, err := resolveADBSerial("", []adb.DeviceInfo{{Serial: "device-1"}, {Serial: "device-2"}}, func([]deviceMenuItem) (string, error) {
return "", wantErr
}, nil)
if !errors.Is(err, wantErr) {
t.Fatalf("err = %v, want %v", err, wantErr)
}
if serial != "" {
t.Fatalf("serial = %q, want empty", serial)
}
if !prompted {
t.Fatal("prompted = false, want true")
}
}

func TestResolveADBSerialExplicitSerialDoesNotPrompt(t *testing.T) {
called := false
serial, prompted, err := resolveADBSerial("requested", []adb.DeviceInfo{{Serial: "device-1"}, {Serial: "device-2"}}, func([]deviceMenuItem) (string, error) {
called = true
return "", nil
}, nil)
if err != nil {
t.Fatalf("resolveADBSerial returned error: %v", err)
}
if serial != "requested" {
t.Fatalf("serial = %q, want requested", serial)
}
if prompted {
t.Fatal("prompted = true, want false")
}
if called {
t.Fatal("selector was called for explicit serial")
}
}

func TestBuildDeviceMenuItemsFallsBackForUnauthorizedDevice(t *testing.T) {
items := buildDeviceMenuItems([]adb.DeviceInfo{{Serial: "device-1", State: "unauthorized"}}, nil)
if len(items) != 1 {
t.Fatalf("got %d items, want 1", len(items))
}
if items[0].Title != "device-1" {
t.Fatalf("title = %q, want device-1", items[0].Title)
}
if items[0].Status != "(unauthorized)" {
t.Fatalf("status = %q, want (unauthorized)", items[0].Status)
}
}
Loading
Loading